4.0 CheatDroid is rebuilt from scratch, and a 2.x license carries over. What changed
CheatDroid

Protecting PlayerPrefs and SharedPreferences from Editing

Unity’s PlayerPrefs on Android are an ordinary shared preferences file, /data/data/<package>/shared_prefs/<package>.v2.playerprefs.xml. Ints and floats are stored as they are, strings percent-encoded. A native app’s SharedPreferences sit in the same folder in the same format. Any root file manager opens them, and CheatDroid’s first screen exists for exactly this.

This is Perfect Slices 1.4.55 after one level:

<int name="coinsAmount" value="30" />

Changing 30 to 999999 and starting the game again is the whole Perfect Slices tutorial. No search, no second step.

What doesn’t help

Renaming keys. A key called a7f3 that went from 30 to 144 after a round is the coin counter, whatever it’s called, and finding it takes two snapshots of the folder. Base64 is a step people take next; Ballz keeps a copy of its coins in a base64 string, and that’s one decode away from JSON.

Scrambling the values with a fixed scheme does slow a file editor down. Knife Hit and Dunk Shot store names and values as runs of capital letters, and I didn’t bother. But whoever reads the game’s code once can write a converter, and after that every save is open. What you want is something an editor can’t redo without a secret.

Sign the value

Store a MAC next to each value you care about and check it on load. An edited value fails the check and the game ignores it. CheatDroid can change the number, but it can’t produce a matching MAC without the key.

using System;
using System.Security.Cryptography;
using System.Text;
using UnityEngine;

public static class SignedPrefs
{
    // Ships in the build. Not a secret from a decompiler, only from a file editor.
    static readonly byte[] Key = Convert.FromBase64String("REPLACE+WITH+32+RANDOM+BYTES+IN+BASE64=");

    public static void SetInt(string name, int value)
    {
        PlayerPrefs.SetInt(name, value);
        PlayerPrefs.SetString(name + ".mac", Mac(name, value));
    }

    public static int GetInt(string name, int fallback = 0)
    {
        int value = PlayerPrefs.GetInt(name, fallback);
        return PlayerPrefs.GetString(name + ".mac") == Mac(name, value) ? value : fallback;
    }

    static string Mac(string name, int value)
    {
        using var hmac = new HMACSHA256(Key);
        return Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(name + "=" + value)));
    }
}

The same in Kotlin, for SharedPreferences:

fun mac(name: String, value: Int): String {
    val mac = Mac.getInstance("HmacSHA256").apply { init(SecretKeySpec(KEY, "HmacSHA256")) }
    return Base64.encodeToString(mac.doFinal("$name=$value".toByteArray()), Base64.NO_WRAP)
}

Three things the snippet leaves to you:

  • Players who already have coins have no MAC yet. Accept an unsigned value once, sign it, and enforce from then on, or the update empties every wallet.
  • Decide what a failed check does. Falling back to the default is fair. A silent ban hits the player whose phone died mid-write as hard as the one who cheated.
  • Mixing a device ID into the MAC stops people copying a rich player’s file, and it breaks Android’s Auto Backup: a save restored onto a new phone fails the check. Pick one.

The key ships inside the APK. Against CheatDroid that doesn’t matter, because a file editor doesn’t read your code. If it matters to you, keep the key in the Android Keystore, where it never leaves the phone’s secure hardware, and use it from a native plugin. Or keep the balance on a server, which is the only version a decompiler can’t touch.

What to leave plain

Sound on or off, the last selected skin, whether the tutorial was shown. Sign what is worth something to a cheater: currencies, unlocks, progress, timers that gate rewards.