Control Gaps in Game Logic
Three toy game controls reveal a recurring mismatch between the checked proxy and the effect that actually changes state.
Cheatsheet for Lesson 15.8
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
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.
What all three examples have in common
Section titled “What all three examples have in common”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 bonusResult: 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
Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.
permission = no; amount = 25.
Step 2, line 1. The presentation flag is true in every trial.
permission = no; amount = 25; ui_unlocked = true; bonus = 0.
Step 3, line 2. The simulation owner supplies permission.
permission = no; amount = 25; ui_unlocked = true; bonus = 0; engine_permission = false.
Step 4, line 3. Calculate the bounded integer candidate.
permission = no; amount = 25; ui_unlocked = true; bonus = 0; engine_permission = false; candidate = 25.
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.
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.
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:
Recipe 1: a client-visible flag guards an engine effect
Section titled “Recipe 1: a client-visible flag guards an engine effect”Minimal control and promised invariant
Section titled “Minimal control and promised invariant”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.
Why it fails
Section titled “Why it fails”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.
Request award_bonus(25)
Step 1: Script to Handler
A script calls the command directly, without the button.
Control read ui_button_unlocked: true
Step 2: Handler to UI flag
Restored UI state left the presentation flag true.
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.
Request award_bonus(25)
Step 1: Script to Handler
The same direct call arrives.
Control read engine policy: no awards
Step 2: Handler to Engine
The UI flag is ignored; the engine’s own policy decides.
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.
Minimal reproduction
Section titled “Minimal reproduction”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 falseThe contradiction is the evidence: allowed = true and bonus = 25 while
the engine-owned policy is false.
Repair and telemetry
Section titled “Repair and telemetry”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”Minimal control and promised invariant
Section titled “Minimal control and promised invariant”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.
Why it fails
Section titled “Why it fails”The checksum answers only, “Did the health field change?” Changing
maximum_health can make the relation impossible while the stored weak
checksum still matches.
health. Changing max_health from 100 to 60 breaks health <= max_health, but the stored checksum still matches — it never looked at max_health.Minimal reproduction
Section titled “Minimal reproduction”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 changeassert!(!stats.completely_valid()); // full relation also checks 80 <= 60This is a coverage bypass inside the small record: the mutation falls outside the measured bytes but changes the meaning of the protected state.
Repair and telemetry
Section titled “Repair and telemetry”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”Minimal control and promised invariant
Section titled “Minimal control and promised invariant”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.
Why it fails
Section titled “Why it fails”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.
Minimal reproduction
Section titled “Minimal reproduction”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 100No signal is hidden or modified. The example simply demonstrates that absence of one signal says nothing about the validity of the requested state.
Repair and telemetry
Section titled “Repair and telemetry”The repaired method creates two independent events:
- an environment-observation event records whether the flag was present;
- 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.
Compare the three failures
Section titled “Compare the three failures”| 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.
Write a complete regression pair
Section titled “Write a complete regression pair”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 changeAlso assert that a denial event is emitted. A silent refusal can preserve state while leaving diagnosis and coverage testing blind.
Glossary terms introduced here
Section titled “Glossary terms introduced here”- 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.
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?
The environment signal is only diagnostic evidence. The health relation belongs at the state-changing boundary and must hold whether the signal is present or absent.
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.
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.