Codes and Redemption
Redemption codes grant tools, materials, or cosmetics from a player-entered string. scripts/core/CodeManager.gd runs the redemption flow, data/codes.json is keyed by SHA-256 so no plaintext code ships in the exported pck, and one-time redemption is tracked in config.json.
Redemption flow
CodeManager.redeem(raw_code) is the single entry point. It returns a result dict { "success": bool, "message": String, "color": Color } and also emits a toast via EventBus.show_toast (visible briefly after the Settings panel closes; callers behind a modal should surface message/color inline instead).
The flow:
- Normalize —
raw_code.strip_edges().to_upper(). An empty code returns a warning immediately. - Look up —
DataRegistry.get_code(code). This is the datamine-safe step (see below). An empty result means "Unknown code." - Check one-time status — if the code is not
repeatableandSaveManager.is_code_redeemed(code)is true, it has been redeemed before. Rather than reject outright,CodeManagerback-fills only newly added, un-owned cosmetics via_grant_new_cosmetics()— tools and materials are never re-granted. If nothing was back-filled, it returns "Code already redeemed." - Grant —
_grant_rewards(def.get("rewards", {}))applies the rewards block and returns a human-readable summary. - Mark redeemed — for non-repeatable codes,
SaveManager.mark_code_redeemed(code).
The rewards block
_grant_rewards() handles three reward categories, each optional:
| Key | Shape | Applied via |
|---|---|---|
tools |
{ tool_id: count } |
ToolState.add_tool(tool_id), once per count |
materials |
{ mat_id: count } |
Inventory.add(mat_id, count) |
cosmetics |
[ cosmetic_id, ... ] |
CosmeticManager.unlock(cosmetic_id) |
Because CosmeticManager.unlock() is a no-op once an item is owned, a cosmetic already held is summarized as "NAME (already owned)" rather than double-granted.
Farm safety
Tools and materials are farmable rewards; they must never be handed out twice. Cosmetics are account-unique, so re-granting one is harmless. That asymmetry is what allows step 3: a code can gain new cosmetic rewards after a player first redeemed it (say, a suit added to an existing code), and _grant_new_cosmetics() back-fills only the un-owned cosmetics from that block, ignoring tools and materials entirely. The code stays flagged redeemed throughout.
data/codes.json is keyed by SHA-256
The catalog does not store plaintext codes. Each entry is keyed by the SHA-256 hex digest of the uppercase code string, so the plaintext cannot be recovered by datamining the shipped pck. The lookup hashes the player's input at read time:
func get_code(code_id: String) -> Dictionary:
return codes.get(code_id.strip_edges().to_upper().sha256_text(), {})
So CodeManager passes the already-uppercased plaintext to DataRegistry.get_code, which normalizes again and hashes with sha256_text() before the dictionary lookup. A wrong code hashes to a key that is not present and returns {}.
A catalog entry (keyed by the hash) looks like:
"<sha256-hex-of-uppercase-code>": {
"repeatable": false,
"rewards": {
"tools": { "steel_pickaxe": 1 },
"materials": {},
"cosmetics": ["form_dave"]
}
}
One-time tracking in config.json
Non-repeatable codes are recorded account-wide so they survive slot deletion and cannot be farmed:
func is_code_redeemed(code: String) -> bool:
var redeemed: Array = _load_config().get("redeemed_codes", [])
return code in redeemed
redeemed_codes lives in config.json. Note that this list stores the plaintext the player typed (uppercased), matching pre-hashing config entries — the hashing applies to the codes.json catalog, not to the redemption ledger. mark_code_redeemed(code) appends the uppercased plaintext if not already present.
Do not confuse the two representations:
codes.jsonis keyed by the SHA-256 hash (datamine-safe), whileconfig.json'sredeemed_codesholds the plaintext code for one-time tracking. The catalog never contains plaintext; the ledger never contains hashes.
Adding a new code
The plaintext-to-hash mapping is never committed to the shipped repo — it lives only in the private repo's git history (the pre-hashing codes.json) and the publisher's records. To add a code, hash it and add the hash-keyed entry:
- Choose the code string and uppercase it.
- Compute its SHA-256 hex digest. In Godot:
"YOURCODE".sha256_text(). Outside Godot:python -c "import hashlib;print(hashlib.sha256('YOURCODE'.encode()).hexdigest())"(with the uppercased string). - Add a new key to
data/codes.jsonusing that hex digest, withrepeatableand arewardsblock. - Never add the plaintext code, a comment, or a hinting label to
codes.json— the file's_commentdocuments this rule, and dev/cheat codes must be stripped before merging to main. - Run the data validator after any
data/*.jsonedit.
Key files
| File | Role |
|---|---|
scripts/core/CodeManager.gd |
redeem(), reward granting, back-fill, one-time enforcement |
data/codes.json |
Code catalog keyed by SHA-256 of the uppercase code |
scripts/resources/DataRegistry.gd |
get_code() — hashes input and looks up the catalog |
scripts/core/SaveManager.gd |
is_code_redeemed() / mark_code_redeemed(); redeemed_codes in config.json |