Skip to content
Lesson 4.9Game State and Bounded Decisions

When Changed State Becomes an Event

Turn repeated polls into meaningful events with snapshot differences, debouncing, confirmation, and cooldowns.

Lesson 4.9
Read or listenPrint chapter or book
About 25 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← →
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 4.9Key ideas, reference and questions

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
Download cheatsheet (.md)

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.

An observer might read the same fact twenty times per second:

gold = 120
gold = 120
gold = 120
gold = 145
gold = 145

A 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:

Poll a snapshot
Validate its fields
Compare with accepted state
Wait for a stable change
Emit one event
Check safety and cooldown
Perform one action

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:

poll 1false
poll 2false
poll 3true
poll 4true
poll 5true
poll 6false
while-open check: clicks on 3 polls
edge: MenuOpened
edge: MenuClosed
Checking the current menu-open state can click three times. Comparing adjacent samples instead reports MenuOpened once and MenuClosed once at the two highlighted polls.

A 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:

A held check and a new-press check, sample by sampleThe six samples are released, released, pressed, pressed, pressed, released. Each check compares what it needs and toggles a lamp when it fires.
  1. 1 Sample 3: released before, pressed now

    held check: togglepressed now
    new-press check: togglepressed now, released before

    The key went down between samples 2 and 3, so both checks fire.

  2. 2 Samples 4 and 5: pressed before, pressed now

    held check: togglestill pressed
    new-press check: quietpressed before too

    A held key fires the held check again on each sample. The new-press check sees no change.

  3. 3 Sample 6: released. The totals

    Toggles over six samples
    held check: 3 toggles
    new-press check: 1 toggle

    Releasing 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.

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:

Watch it happenA value becomes an event only at its edge

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
  1. The accepted baseline holds 120 gold, menu closed. Identity and read checks have already passed.
  2. The next accepted snapshot holds 145 gold, menu open. These are endpoints, not an action history.
  3. Compare gold 120 with 145: +25. The menu changes from false to true. Only differences emit events.
  4. The differences become GoldChanged and MenuOpened. GoldChanged carries both endpoints; a menu event names its direction.
  5. The accepted snapshot becomes the new baseline: 145 gold, menu open. Its changes have now been reported once.
  6. 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.

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 scene
poll 2 health 0 object freed, memory not yet reused
poll 3 health 0
poll 4 health 3452816845 new allocation, uninitialized bytes (0xCDCDCDCD)
poll 5 health 100 the new scene, fully constructed

The 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:

poll 1100
poll 20
poll 30
poll 40xCDCDCDCD
poll 5100
poll 680
poll 780
poll 880
candidate 0: count 1, 2
new candidate: 1
candidate 80: count 1, 2, 3
accepted stays 100: no event
accepted becomes 80
No glitch value lasts three polls in a row, so none of them reaches the bot, and poll 5 clears the candidate by matching the accepted 100. 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.

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:

bot sends end turn once
old turn and polls_left above 0, count down by 1
turn changed, confirmed
old turn and polls_left is 0, timed out
Idle
Pending

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.

  1. Copy and validate a snapshot.
  2. Diff it against the previous accepted snapshot.
  3. Debounce noisy state.
  4. Feed events to the state machine.
  5. Propose at most one bounded action.
  6. Wait for its confirmation or timeout.
  7. 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.

Knowledge check

React once

Why diff two accepted snapshots before sending them to a bot state machine?

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.

Chapter 4: 10 lessons

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.