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:

  1. Normalizeraw_code.strip_edges().to_upper(). An empty code returns a warning immediately.
  2. Look upDataRegistry.get_code(code). This is the datamine-safe step (see below). An empty result means "Unknown code."
  3. Check one-time status — if the code is not repeatable and SaveManager.is_code_redeemed(code) is true, it has been redeemed before. Rather than reject outright, CodeManager back-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."
  4. Grant_grant_rewards(def.get("rewards", {})) applies the rewards block and returns a human-readable summary.
  5. 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.json is keyed by the SHA-256 hash (datamine-safe), while config.json's redeemed_codes holds 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:

  1. Choose the code string and uppercase it.
  2. 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).
  3. Add a new key to data/codes.json using that hex digest, with repeatable and a rewards block.
  4. Never add the plaintext code, a comment, or a hinting label to codes.json — the file's _comment documents this rule, and dev/cheat codes must be stripped before merging to main.
  5. Run the data validator after any data/*.json edit.

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