Skip to content
Lesson 15.8Defensive Design and Anti-Cheat

Control Gaps in Game Logic

Three toy game controls reveal a recurring mismatch between the checked proxy and the effect that actually changes state.

Lesson 15.8
Read or listenPrint chapter or book
About 58 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 15.8Key ideas, reference and questions

A one-page summary of “Control Gaps in Game Logic” you can keep beside your notes. Plain Markdown.

Lesson 15.8 — Control Gaps in Game Logic: cheatsheet

  • Remember: A repair needs both a rejected invalid case and an accepted valid case, so stopping everything cannot masquerade as correctness.
  • Also useful: A disabled button is a sign on a doorway; the engine’s effect boundary is the actual gate. Other request paths may never pass the sign.

Key terms

  • coverage bypass: This is a coverage bypass inside the small record: the mutation falls outside the measured bytes but changes the meaning of the protected state.

Review

  • Q: A health command is accepted whenever an observer flag is absent, even when health exceeds its maximum. What is the strongest repair? A: Validate the health range at the effect boundary; record the signal separately
  • Q: Controls such as a greyed-out button or a one-field checksum check a stand-in for the rule. What weakness do they share? A: They check something near the property, not the property
Download cheatsheet (.md)

The next three examples each fit in one small state model. For each, identify the invariant and canonical state, then compare a checksum or environment signal with the decision telemetry and the effect. A paired regression test should show both the control gap and the legitimate behavior a repair must retain.

Every control in this lesson makes a promise, and all three break it the same way: each one checks something near the thing it cares about instead of the thing itself.

A greyed-out button stands in for “this player is allowed to do that.” A checksum over one field stands in for “this record is intact.” A debugger flag stands in for “this request is honest.” Every stand-in is easier to check than the real property, which is precisely why someone reached for it — and every one of them can be true while the real property is false.

Once you see that shape, the repair stops being a guess. It is never “add more stand-ins.” It is to move the check to the place where the effect actually happens, and demote the stand-in to a clue that helps explain what went wrong.

The engine checks an award at the effect boundary. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

ui_unlocked = true; bonus = 0
read engine-owned permission
candidate = bonus + amount
require permission and candidate <= 100
apply only the accepted candidate
emit the decision and final bonus

Result: EnginePolicyDenied, bonus=0. The same unlocked button appears beside both accepted and rejected effects.

Final values: permission = no; amount = 25; ui_unlocked = true; bonus = 0; engine_permission = false; candidate = 25; allowed = false; reason = EnginePolicyDenied.

Read every step of the lesson’s example
  1. Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.

    permission = no; amount = 25.

  2. Step 2, line 1. The presentation flag is true in every trial.

    permission = no; amount = 25; ui_unlocked = true; bonus = 0.

  3. Step 3, line 2. The simulation owner supplies permission.

    permission = no; amount = 25; ui_unlocked = true; bonus = 0; engine_permission = false.

  4. Step 4, line 3. Calculate the bounded integer candidate.

    permission = no; amount = 25; ui_unlocked = true; bonus = 0; engine_permission = false; candidate = 25.

  5. Step 5, line 4. The decision checks the actual effect conditions.

    permission = no; amount = 25; ui_unlocked = true; bonus = 0; engine_permission = false; candidate = 25; allowed = false; reason = EnginePolicyDenied.

  6. Step 6, line 5. Keep the previous bonus unchanged.

    permission = no; amount = 25; ui_unlocked = true; bonus = 0; engine_permission = false; candidate = 25; allowed = false; reason = EnginePolicyDenied.

  7. Step 7, line 6. Result: EnginePolicyDenied, bonus=0. The same unlocked button appears beside both accepted and rejected effects.

    permission = no; amount = 25; ui_unlocked = true; bonus = 0; engine_permission = false; candidate = 25; allowed = false; reason = EnginePolicyDenied.

Use one rigorous analysis recipe every time

Section titled “Use one rigorous analysis recipe every time”

Each experiment follows the same sequence:

State the promised invariant
Locate the enforcing branch
Name its unchecked assumption
Reproduce one contradiction
Capture decision and effect
Move validation to the effect boundary
Retest failure and valid behavior

Recipe 1: a client-visible flag guards an engine effect

Section titled “Recipe 1: a client-visible flag guards an engine effect”

The UI has an “award bonus” button. A weak implementation treats the button’s ui_button_unlocked flag as permission to change the score:

pub fn weak_award(&mut self, amount: u32) -> DecisionEvent {
if !self.ui_button_unlocked {
return DecisionEvent {
operation: "award_bonus",
allowed: false,
reason: DecisionReason::UiFlagMissing,
};
}
self.bonus = self.bonus.saturating_add(amount);
DecisionEvent {
operation: "award_bonus",
allowed: true,
reason: DecisionReason::Allowed,
}
}

The promised invariant is: bonus changes only when the engine policy permits an award and the result stays within the configured maximum.

Presentation state answers “should the button look available?” It does not answer “may the simulation perform this effect?” Multiple inputs, scripts, and restored UI state can reach the same engine operation. The weak control places authority in a copy used for presentation.

The button is greyed out, the command still runsThe engine policy says no awards. The weak award_bonus handler never reads it.
  1. Request award_bonus(25)

    Step 1: Script to Handler

    A script calls the command directly, without the button.

  2. Control read ui_button_unlocked: true

    Step 2: Handler to UI flag

    Restored UI state left the presentation flag true.

  3. Data bonus 0 → 25

    Step 3: Handler to Engine

    The effect happens while the policy is false.

The engine policy says no awards. The weak award_bonus handler never reads it.

The handler checks the state it is about to changeThe same call meets the engine policy at the effect boundary.
  1. Request award_bonus(25)

    Step 1: Script to Handler

    The same direct call arrives.

  2. Control read engine policy: no awards

    Step 2: Handler to Engine

    The UI flag is ignored; the engine’s own policy decides.

  3. Reply EnginePolicyDenied, bonus stays 0

    Step 3: Handler to Script

    Refused with a reason, and nothing changes.

The same call meets the engine policy at the effect boundary.

The example changes only the presentation flag before calling the weak path:

let mut game = BonusGame::new(false, 100);
game.set_ui_button_for_example(true);
let event = game.weak_award(25);
assert!(event.allowed);
assert_eq!(game.bonus(), 25); // contradiction: engine policy was false

The contradiction is the evidence: allowed = true and bonus = 25 while the engine-owned policy is false.

The repaired method ignores presentation state at the effect boundary. It checks the engine policy, uses checked addition, enforces the maximum, and emits one of EnginePolicyDenied, ValueOutOfRange, or Allowed.

The regression test keeps the UI flag true and proves the repaired method denies the operation without changing bonus. A second test proves a valid engine policy still permits a bounded award.

Recipe 2: a checksum covers only the convenient field

Section titled “Recipe 2: a checksum covers only the convenient field”

StatsRecord contains player ID, health, maximum health, generation, and a checksum. The weak checksum covers only health:

const fn weak_checksum(health: u16) -> u32 {
u32::from(health).wrapping_mul(31)
}

The promised invariant is: the identity and generation match the same record, the health range is valid, and every field used by that relation is covered.

The checksum answers only, “Did the health field change?” Changing maximum_health can make the relation impossible while the stored weak checksum still matches.

player_id7
health80
max_health100
generation3
checksumweak_checksum(80)
The weak checksum reads only health. Changing max_health from 100 to 60 breaks health <= max_health, but the stored checksum still matches — it never looked at max_health.
let mut stats = StatsRecord::new(7, 80, 100, 3);
stats.change_maximum_without_retagging_for_example(60);
assert!(stats.weakly_valid()); // weak relation misses the change
assert!(!stats.completely_valid()); // full relation also checks 80 <= 60

This is a coverage bypass inside the small record: the mutation falls outside the measured bytes but changes the meaning of the protected state.

The repaired relation covers player ID, health, maximum health, and generation, then separately checks health <= maximum_health. Emit a specific failure such as ChecksumMismatch or OutOfRange; do not compress every failure into one boolean in production code.

The regression suite mutates every covered field individually and asserts that the complete relation fails. It also keeps a valid record to prove verification is not permanently closed.

This checksum detects example-data corruption; it is not authentication. If the system needs to defend against a party who can recompute an unkeyed checksum, the design needs an authenticated owner and key lifecycle, not a more obscure formula.

Recipe 3: one environment signal substitutes for command validation

Section titled “Recipe 3: one environment signal substitutes for command validation”

The weak game refuses set_health only when one observer flag is present. When the signal is absent, it accepts any number:

pub const fn weak_set_health(
&mut self,
value: u16,
signal: EnvironmentSignal,
) -> DecisionEvent {
if matches!(signal, EnvironmentSignal::ObserverFlagged) {
return DecisionEvent {
operation: "set_health",
allowed: false,
reason: DecisionReason::ObserverSignalRecorded,
};
}
self.health = value;
DecisionEvent {
operation: "set_health",
allowed: true,
reason: DecisionReason::Allowed,
}
}

The promised invariant is health <= maximum_health after every accepted command.

The flag describes the environment, not the command. Even a perfectly measured observer signal cannot prove that an unrelated health value is in range. The weak rule therefore has a large ordinary path where invalid data is accepted.

Begin with health 80 and a maximum of 100. The weak function sees an ordinary environment signal, accepts a request for 500, and leaves the game in an invalid state. Both assertions should pass even though the invariant fails.

let mut game = HealthGame::new(80, 100);
let event = game.weak_set_health(500, EnvironmentSignal::Ordinary);
assert!(event.allowed);
assert_eq!(game.health(), 500); // contradiction: maximum is 100

No signal is hidden or modified. The example simply demonstrates that absence of one signal says nothing about the validity of the requested state.

The repaired method creates two independent events:

  1. an environment-observation event records whether the flag was present;
  2. a command-decision event validates value <= maximum_health.

An observer flag can still help diagnose a run, but it neither grants nor removes the health invariant. Tests cover all four combinations of ordinary or flagged environment with valid or invalid values.

Recipe Weak proxy Real invariant Correct boundary Detection evidence
UI flag Presentation state Engine policy and bounded bonus Award effect Allowed event while policy is false
Partial checksum One field Whole identity/range relation Record verification and consumer Weak valid, complete invalid
Environment flag One observer signal Health range Health command Allowed event followed by out-of-range state

All three mistakes confuse a proxy with the property that actually matters. The repair is not “add more proxy checks.” It is to enforce the invariant where the effect becomes real, then use proxy signals as supporting telemetry.

Every repair needs two sides:

negative test:
reproduce old preconditions
expect decision = denied with exact reason
expect canonical state unchanged
positive test:
provide valid preconditions
expect decision = allowed
expect exactly the intended state change

Also assert that a denial event is emitted. A silent refusal can preserve state while leaving diagnosis and coverage testing blind.

  • Proxy: an observable value used as indirect evidence for another property.
  • Coverage bypass: a meaningful change outside the data examined by a control.
  • Effect boundary: the shared point where a requested state change becomes canonical.
  • Negative regression test: proves the previously failing case is refused.
  • Positive regression test: proves valid behavior still works after repair.
  • Contradiction: evidence that a control decision and resulting state cannot both satisfy the promised invariant.

The three toy failures differ in their proxies, but each lets an effect happen without checking the promised invariant at the shared boundary. Each repair keeps both sides of the evidence: the formerly accepted invalid case is rejected, and valid behavior still works. Lesson 12.10 applies the same rule to eight more failure modes.

Knowledge check

Repair the property, not the proxy

A health command is accepted whenever an observer flag is absent, even when health exceeds its maximum. What is the strongest repair?

Finished Lesson 15.8?

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 15: 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.