When Changed State Becomes an Event
Turn repeated polls into meaningful events with snapshot differences, debouncing, confirmation, and cooldowns.
Cheatsheet for Lesson 4.9
A one-page summary of “When Changed State Becomes an Event” you can keep beside your notes. Plain Markdown.
Lesson 4.9 — When Changed State Becomes an Event: cheatsheet
- Remember: A snapshot says what is true now, and an event says what changed. Acting on the change, not on every poll, stops repeated actions.
- Also useful: Think of a lift button that must be pressed steadily before it counts: one flicker of contact is ignored. Debouncing asks for the same steady agreement.
Key ideas
- After reporting a change, replace the saved baseline with the newly accepted snapshot. Comparing the next identical snapshot with that baseline emits no second event.
Formulas and patterns
gold = 120;gold = 120;gold = 120;gold = 145;gold = 145
Review
- Q: Why diff two accepted snapshots before sending them to a bot state machine? A: It turns repeated polled values into one-time change events
- Q: What is the difference between a snapshot and an event? A: A snapshot says what is true now; an event says what changed
A route planner can compute one good path and still make a bad bot if it treats every read of the same target position as a fresh command. The difference between “the enemy is at this tile” and “the enemy just moved to this tile” is the beginning of an event-driven design.
Polling is not the same as reacting
Section titled “Polling is not the same as reacting”An observer might read the same fact twenty times per second:
gold = 120gold = 120gold = 120gold = 145gold = 145A responsive system should usually produce one event:
GoldChanged { before: 120, after: 145 }The event says what changed, while a snapshot says what is true now. Keeping both ideas separate prevents a bot from performing the same action on every poll.
A responsive loop should turn noisy polls into one deliberate action in stages, rather than connecting every poll directly to a click:
Validation protects the model, debouncing protects it from flicker, and the cooldown prevents a valid event from producing frantic repeated input.
Level facts and edge facts answer different questions
Section titled “Level facts and edge facts answer different questions”A snapshot contains level facts: “the menu is open” or “gold equals 145.” An event describes an edge between two accepted snapshots: “the menu opened” or “gold increased by 25.”
That difference controls behavior. A level-triggered check such as if menu_open { click() } may click on every poll. A transition-triggered handler acts only on the transition from closed to open. Neither form is always better:
- use a level fact to keep a safety condition true, such as “stop while the target is absent”;
- use an edge event for one-time work, such as recording that a turn began;
- use both when an action needs a starting event and an ongoing confirmation.
Here are six polls of menu_open and what each kind of check does with them:
MenuOpenedMenuClosedA held button has the same distinction. Suppose each input check toggles a lamp when it fires. Checking the pressed state fires on every held sample; detecting a new press fires only when released becomes pressed:
1 Sample 3: released before, pressed now
held check: togglepressed nownew-press check: togglepressed now, released beforeThe key went down between samples 2 and 3, so both checks fire.
2 Samples 4 and 5: pressed before, pressed now
held check: togglestill pressednew-press check: quietpressed before tooA held key fires the held check again on each sample. The new-press check sees no change.
3 Sample 6: released. The totals
Toggles over six samplesheld check: 3 togglesnew-press check: 1 toggleReleasing fires neither check. Over the six samples the held check toggled on samples 3, 4 and 5, the new-press check only on 3.
The six samples are released, released, pressed, pressed, pressed, released. Each check compares what it needs and toggles a lamp when it fires.
This simulation needs JavaScript. The lesson text around it works through the same numbers.
Events are interpretations, not raw truth. If the observer misses snapshots, 120 -> 145 proves the endpoints but not whether the game passed through 130. Name an event GoldChanged, not GoldEarned, unless something else shows why it changed.
Diff two validated snapshots
Section titled “Diff two validated snapshots”Compare two snapshots only after each has passed its own read and identity
checks. If gold changes from 120 to 145 while menu_open changes from
false to true, diff returns both GoldChanged { before: 120, after: 145 }
and MenuOpened. Repeated identical snapshots return an empty vector, so
the bot has no new event to act on.
#[derive(Clone, Debug, PartialEq)]struct StrategySnapshot { turn: u32, gold: u32, selected_unit: Option<u32>, menu_open: bool,}
#[derive(Clone, Debug, Eq, PartialEq)]enum GameEvent { TurnChanged { before: u32, after: u32 }, GoldChanged { before: u32, after: u32 }, SelectionChanged { before: Option<u32>, after: Option<u32> }, MenuOpened, MenuClosed,}
fn diff(before: &StrategySnapshot, after: &StrategySnapshot) -> Vec<GameEvent> { let mut events = Vec::new();
if before.turn != after.turn { events.push(GameEvent::TurnChanged { before: before.turn, after: after.turn }); } if before.gold != after.gold { events.push(GameEvent::GoldChanged { before: before.gold, after: after.gold }); } if before.selected_unit != after.selected_unit { events.push(GameEvent::SelectionChanged { before: before.selected_unit, after: after.selected_unit, }); } match (before.menu_open, after.menu_open) { (false, true) => events.push(GameEvent::MenuOpened), (true, false) => events.push(GameEvent::MenuClosed), _ => {} }
events}Why use an enum? Each variant defines exactly which data belongs to that event. A typo in a string such as "turn_chagned" cannot silently create a new event type.
Follow the two accepted snapshots through diff, then compare an unchanged
poll. The endpoints reveal changes, not the reason gold changed:
Both snapshots here have already passed validation. Change their values, then replay the comparison, event creation and baseline update. An identical following snapshot emits nothing.
120 gold and menu false become 145 gold and menu true. First events: GoldChanged, MenuOpened. An identical next snapshot emits no events.
Read the steps
- The accepted baseline holds 120 gold, menu closed. Identity and read checks have already passed.
- The next accepted snapshot holds 145 gold, menu open. These are endpoints, not an action history.
- Compare gold 120 with 145: +25. The menu changes from false to true. Only differences emit events.
- The differences become GoldChanged and MenuOpened. GoldChanged carries both endpoints; a menu event names its direction.
- The accepted snapshot becomes the new baseline: 145 gold, menu open. Its changes have now been reported once.
- An identical following snapshot has 145 gold and the same menu state. That is a continuing level, not another edge: diff returns no events.
Level state persists across polls; an edge event describes the difference between accepted states.
Debouncing asks for stability
Section titled “Debouncing asks for stability”Some values flicker while a game changes scenes. Debouncing waits for the same candidate value to appear several times before accepting it.
It helps to know why a value flickers, because that tells you how long to wait. During a scene change the game is often rebuilding the very object you are reading. The pointer is briefly null, or it points at a freshly allocated record whose fields have not been filled in yet. Your read succeeds and hands back a real number that was never a real game state:
poll 1 health 100 the old scenepoll 2 health 0 object freed, memory not yet reusedpoll 3 health 0poll 4 health 3452816845 new allocation, uninitialized bytes (0xCDCDCDCD)poll 5 health 100 the new scene, fully constructedThe strange value on poll 4 is the byte 0xCD, which Microsoft’s debug heap
writes into every fresh allocation, repeated four times. One 0xCD is
12 × 16 + 13 = 205, so the four bytes read as
205 × 256³ + 205 × 256² + 205 × 256 + 205 = 3,452,816,845.
A debouncer keeps two things: the last accepted value, and a candidate value with a count. Each poll does one of three things. A poll equal to the accepted value clears the candidate. A poll equal to the candidate adds one to its count. Any other value becomes a new candidate with a count of 1. When the candidate’s count reaches the required number, it becomes the accepted value and the debouncer reports one change.
With three required polls, here is the trace above, followed by 20 points of real damage that take health from 100 to 100 − 20 = 80:
0: count 1, 280: count 1, 2, 3100: no event80100. The real change is accepted on its third matching poll, poll 8, and produces exactly one event.The code follows those three cases:
#[derive(Debug)]struct Debouncer<T> { accepted: T, candidate: Option<(T, u8)>, required_samples: u8,}
impl<T: Clone + Eq> Debouncer<T> { fn observe(&mut self, value: T) -> Option<T> { if value == self.accepted { self.candidate = None; return None; }
match &mut self.candidate { Some((candidate, count)) if *candidate == value => *count += 1, _ => self.candidate = Some((value.clone(), 1)), }
let stable = self.candidate .as_ref() .is_some_and(|(_, count)| *count >= self.required_samples); if stable { self.accepted = value.clone(); self.candidate = None; Some(value) } else { None } }}Debouncing buys correctness with latency, and the cost is easy to work out. At twenty polls per second, polls are 1000 ÷ 20 = 50 milliseconds apart. The change is accepted on the third poll that shows it, which comes two gaps, 2 × 50 = 100 milliseconds, after the first. That first poll can itself arrive up to 50 milliseconds after the real change, so every genuine change reaches the bot 100 to 150 milliseconds late. “Three polls” means nothing without the polling rate: at 100 polls per second the gaps are 10 milliseconds, and the same three-poll debounce adds only 20 to 30 milliseconds.
That delay is cheap for “the match ended” and far too slow for “an enemy appeared.” Choose the requirement per value rather than once for the whole bot, and use debouncing for noisy or safety-critical transitions, not for every coordinate in a smooth animation.
Add cooldowns after actions
Section titled “Add cooldowns after actions”After requesting “end turn,” wait until a snapshot shows that the turn changed. Do not send the same input again merely because the next 50 ms poll still shows the old turn.
A useful action record contains:
- the requested action;
- the time it was requested;
- the state change that will confirm success;
- a timeout;
- a cancellation reason.
The pending action is a small state machine of its own. While it exists, the bot sends nothing, and each poll either confirms it, counts it down, or times it out:
The timeout is a number of polls, so it becomes a time through the polling
rate: starting from polls_left: 20 at one poll every 50 milliseconds gives the
game 20 × 50 = 1000 milliseconds, one second, to show the new turn.
enum PendingAction { EndTurn { old_turn: u32, polls_left: u8 },}
fn update_pending(action: &mut Option<PendingAction>, snapshot: &StrategySnapshot) { let Some(PendingAction::EndTurn { old_turn, polls_left }) = action else { return; };
if snapshot.turn != *old_turn || *polls_left == 0 { *action = None; // confirmed or timed out; either way, stop repeating input. } else { *polls_left -= 1; }}In a full program, distinguish confirmed success from timeout in the result. The short sample focuses on the stop condition.
A safe responsive loop
Section titled “A safe responsive loop”- Copy and validate a snapshot.
- Diff it against the previous accepted snapshot.
- Debounce noisy state.
- Feed events to the state machine.
- Propose at most one bounded action.
- Wait for its confirmation or timeout.
- Stop immediately if the target disappears or the user cancels.
This design is useful far beyond game automation. File watchers, user interfaces, network clients, and monitoring tools all turn repeated polls into meaningful events while controlling duplicate work.
Make repeated requests idempotent when you can: sending the same request twice should have the same final effect as sending it once. “Select entity 7” can be idempotent; “move selection to the next entity” is not. Idempotence does not replace cooldowns, but it limits damage when a timeout hides a successful first action.
React once
Why diff two accepted snapshots before sending them to a bot state machine?
A snapshot states what is true now; a diff identifies what changed, preventing one transition from firing on every poll.
Finished Lesson 4.9?
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.