Skip to content
Journey to Code

Part 15 of 15

Rustbound

Cheaters Are Going to Cheat.

Validate Untrusted Saves in Rust Without Pretending You Can Stop Cheating

· By · 6 min read

View as Markdown

Cover reading "Cheaters Are Going to Cheat." contrasting a tampered save with the Rust engine response: repair, refuse, or accept, with handwritten notes "Rules know" and "Plausible stays"

Our game trusts a text file.

Open the browser's developer tools, find rustbound-idle.save in Local Storage, and change one number. The engine loads it, says nothing, and carries on with eleven million gold.

Cheaters are going to cheat.

Today we decide what the engine should do about that. The answer is not "stop them." We will see why that option is not on offer.

The useful answer is smaller: stop believing everything a save claims, repair what the rules already know, and refuse state that could never have come from playing.

Our starting point#

Everything here builds on Article 14. The article_14 branch of the companion repository has the exact starting checkpoint.

Articles 12 through 14 changed the browser around the engine. The Rust save boundary still comes from Article 10.

src/save.rs reads a small SaveData instead of deserializing the whole Game:

Rust
#[derive(Serialize, Deserialize)]
struct SaveData {
    save_version: u32,
    stage: u32,
    player: PlayerSave,
    enemy_health: u32,
}

That choice already helps. Enemy name and rewards are not in the save at all. Loading builds a fresh enemy from the stage:

Rust
game.enemy = Enemy::for_stage(save.stage);
game.enemy.health = save.enemy_health;

The save can lie about health. It cannot lie about what the enemy is called or what it pays, because we never ask it.

Run the tests before changing anything:

Terminal
cargo test

Thirty-two tests pass.

What Rust already refuses#

Here is a new-game save:

json
{"save_version":1,"stage":1,"player":{"gold":0,"xp":0,"level":1,"click_upgrade_level":0},"enemy_health":20}

Replace a number with a word, -5, 1.5, or 4294967296. Every field is a u32, and Serde refuses to turn those values into one.

Delete a required field, and the save is Unreadable. Change the version, and it becomes UnsupportedVersion.

We did not write a special check for negative gold. The type already has no way to represent it.

What u32 cannot tell us is whether a number makes sense.

Why does a negative gold value fail before our validation code runs?

Three kinds of wrong#

An edited save can be wrong in three different ways.

KindExampleEngine response
Derivedstage-1 enemy with four billion healthrepair it from the rules
Impossiblestage 0refuse the whole save
Generouseleven million goldaccept it

Derived means the rules know the answer. A stage-1 enemy has at most 20 health.

Impossible means play cannot produce the state, and guessing would invent a different game. Stage 0 is not a place. Level 0 is not a level.

Generous means play could have produced the value. Eleven million gold is suspicious. It is not impossible.

Try the three categories below. Change the numbers and ask the engine what it can honestly decide.

Inspect an untrusted save
---html
<h2>Save inspector</h2>
<label>Stage <input id="stage" type="number" min="0" value="1" /></label>
<label>Level <input id="level" type="number" min="0" value="1" /></label>
<label>Gold <input id="gold" type="number" min="0" value="11000000" /></label>
<label>Enemy health <input id="health" type="number" min="0" value="4000000000" /></label>
<button id="inspect" type="button">Load save</button>
<pre aria-live="polite"></pre>
---css
body { font: 16px/1.5 system-ui; max-width: 38rem; margin: 2rem auto; padding: 0 1rem; color: #f4f1e8; background: #15171c; }
label { display: grid; grid-template-columns: 9rem 1fr; gap: .75rem; align-items: center; margin: .65rem 0; }
input, button { min-height: 44px; border: 1px solid #737780; border-radius: .3rem; padding: .55rem .7rem; color: inherit; background: #252a33; }
button { width: 100%; margin-top: .75rem; border-color: #ff5522; color: #111; background: #ff5522; font-weight: 700; }
pre { min-height: 7rem; padding: 1rem; white-space: pre-wrap; background: #0d0f13; }
---js
const read = (id) => Number(document.querySelector(`#${id}`).value);
const output = document.querySelector("pre");

document.querySelector("#inspect").addEventListener("click", () => {
  const stage = read("stage");
  const level = read("level");
  const gold = read("gold");
  const health = read("health");

  if (stage === 0 || level === 0) {
    output.textContent = "REFUSED\nThat save could not have come from playing.";
    return;
  }

  const maximumHealth = stage * 10 + 10;
  const repairedHealth = Math.min(health, maximumHealth);
  output.textContent = [
    "LOADED",
    `Gold accepted: ${gold}`,
    `Enemy health: ${health} → ${repairedHealth}`,
    health === repairedHealth ? "The health claim was possible." : "The rules repaired the health claim.",
  ].join("\n");
});

document.querySelector("#inspect").click();

The last row can feel like giving up. It is not. It is the boundary of what the engine can know from one snapshot.

Let a failing test describe the rule#

Start with derived health. Add this test to src/save.rs:

Rust
#[test]
fn an_enemy_cannot_have_more_health_than_its_stage_allows() {
    let mut game = Game::new();
    game.enemy.health = 4_000_000_000;

    let restored = Game::from_saved_json(&game.save_json())
        .expect("the save should load");

    assert_eq!(restored.enemy.health, enemy_health_for_stage(1));
}

The engine currently loads the four billion. The test expects 20.

There is another derived value hiding in the player. add_xp() always spends complete levels before it returns. A level-1 player with 1,000 leftover XP is a state normal play cannot leave behind.

Rust
#[test]
fn leftover_xp_is_turned_into_levels() {
    let mut game = Game::new();
    game.player.xp = 1_000;

    let restored = Game::from_saved_json(&game.save_json())
        .expect("the save should load");

    assert!(restored.player.level > 1);
    assert!(restored.player.xp < restored.player.xp_needed());
}

These tests ask the rules to finish work the save skipped.

Repair what the rules know#

Import the health rule at the top of src/save.rs:

Rust
use crate::rules::enemy_health_for_stage;

Then change the end of from_saved_json():

Rust
game.enemy = Enemy::for_stage(save.stage);
game.enemy.health = save
    .enemy_health
    .min(enemy_health_for_stage(save.stage));

game.player.add_xp(0);

Ok(game)

.min() keeps the smaller number. A saved health of 7 stays 7 because the slime was hurt. Four billion becomes 20 because the stage does not allow more.

game.player.add_xp(0) looks slightly ridiculous. It adds nothing, then runs the existing level-up loop. If XP is already in range, nothing changes. If the save contains several levels of leftover XP, the same rule that handles a reward spends it.

We did not write a second leveling rule for loading. The first one was sitting there, waiting to be asked.

Why do we use .min(saved_health, maximum_health) instead of always rebuilding full health?

Refuse what cannot be repaired#

Stage zero and level zero have no correct replacement. Changing either to one could throw away real progress or invent a player that never existed.

Add a third error:

Rust
#[derive(Debug, PartialEq)]
pub enum LoadError {
    Unreadable,
    UnsupportedVersion(u32),
    Impossible,
}

After the version check, refuse those two states:

Rust
if save.stage == 0 || save.player.level == 0 {
    return Err(LoadError::Impossible);
}

|| means or. Either zero is enough to leave early.

The new enum variant breaks the exhaustive match in src/web.rs. Rust will not compile until the browser boundary gives this failure words.

Add the missing arm:

Rust
Err(LoadError::Impossible) => {
    Err(String::from("That save could not have come from playing."))
}

The message does not call anyone a cheater. An impossible save can come from an old bug, a partial write, or a browser extension. The engine only says what it knows.

Why does adding LoadError::Impossible force us to edit the match in web.rs?

The row we accept#

This test passes the moment we write it:

Rust
#[test]
fn a_great_deal_of_gold_is_still_just_gold() {
    let mut game = Game::new();
    game.player.gold = 11_000_000;

    let restored = Game::from_saved_json(&game.save_json())
        .expect("the save should load");

    assert_eq!(restored.player.gold, 11_000_000);
}

It exists to say that this is deliberate.

Eleven million gold is possible. The save does not include a history, so the engine cannot distinguish a patient player from a text editor.

Run everything:

Terminal
cargo test

The real checkpoint prints:

text
38 passed; 0 failed

That is 36 library tests and two outside integration tests.

Test the real browser boundary#

Nothing in idle-game.js changes. Its existing try/catch already turns any failed Rust load into a fresh game.

Build the WebAssembly release and serve dist/. Then open the console and run:

js
const el = document.querySelector("idle-game");
const { Game } = await import("./pkg/rustbound_idle.js");

const save = JSON.parse(el.game.saveJson());
save.player.gold = 11000000;
save.enemy_health = 4000000000;

const restored = Game.fromJson(JSON.stringify(save));
JSON.parse(restored.stateJson());

The real browser check returned gold 11000000 and enemy health 20.

Now make the save impossible:

js
save.stage = 0;
Game.fromJson(JSON.stringify(save));

The WebAssembly boundary throws:

text
That save could not have come from playing.

The browser runs the same rules the Rust tests exercised. No separate JavaScript validator appeared while we were not looking.

What this does not stop#

We cannot tell earned from given. A save is a snapshot, not a history.

The browser belongs to the player. They can edit Local Storage, call game.tick() from the console, or change the saved timestamp. Any check running on their machine can be skipped on their machine.

Who is hurt? In this game, nobody. A player who gives themselves eleven million gold has made their own idle game less idle. That is a choice about their own afternoon.

What we built is bad-data handling, not anti-cheat security. The engine now:

  • repairs a claim when the rules know the answer;
  • refuses a claim that cannot be true;
  • accepts a possible claim it cannot disprove.

That matters even without cheating. Old bugs, balance changes, and partial saves can all produce bad data.

When cheating matters#

A leaderboard changes the answer.

The moment one player's score is compared with another's, the player's browser is no longer an honest witness. Nothing inside that browser fixes the problem.

Instead of accepting "I have eleven million gold," a server would need trustworthy inputs and its own copy of the rules. It could replay actions and decide what score follows.

The engine is ready for that shape because it is headless. The same crate can run without a browser. We are not building the server here; accounts, timing, and replay rules are another project.

The complete checkpoint#

Article 15 changes two Rust files:

text
src/
├── save.rs    (repair, refusal, and five new tests)
└── web.rs     (one error message and one boundary test)

The article_15 branch holds the tested checkpoint.

What we learned#

  • Rust types reject many malformed values before our code sees them.
  • A valid u32 can still be wrong for the game.
  • Derived, impossible, and merely generous values need different answers.
  • .min() can preserve honest damage while capping impossible health.
  • Existing game rules are better repair tools than duplicate validation rules.
  • Adding an enum variant makes every exhaustive match acknowledge the new failure.
  • Client-side validation protects the game from bad state, not the player from themselves.

Two ideas are worth keeping.

Anything crossing a boundary is a claim. Check what the program can know cheaply, and be exact about what it cannot know.

And cheaters are going to cheat. That does not mean the engine has to believe impossible numbers.

We are done building#

The game runs in a terminal and a browser. It saves, restores, progresses while closed, deploys, installs, opens offline, and now survives a bad save.

The tutorial is finished.

The game is not.

Next time, the way this series works changes. Up to now, I decided what came next. That ends here, and I want to show you how it works from the other side.

Further reading#

Small function. Bigger game.

Comments require JavaScript.