My save file stored which one-time story screens the player had already seen, as an array of level numbers. After one save/load cycle, screens started replaying. The array printed as [4, 15, 22], exactly as written — yet seen.has(22) returned false.
TL;DR — Godot 4’s JSON.parse_string() returns every number as a float, because JSON itself has no integer type. GDScript’s == coerces (22.0 == 22 is true), but the container searches — Array.has(), find(), the in operator, Dictionary.has() — do not, so a stored 22.0 never matches a literal 22. Coerce every known-int field with int() once, at load time.
The setup
The project was a mobile puzzle game saving progress the simplest way Godot offers: one Dictionary, serialized with JSON.stringify(), written to user://progress.save, read back with the JSON parser at startup. The dictionary held plain counters (highest unlocked level, an in-game currency), a per-level star rating, and a few arrays of level numbers used as sets — « story screens already shown », « worlds where the free hint was already spent ». A membership test such as seen.has(level_id) decided whether a one-time event fired again.
What I expected
A round trip to return what I put in. The file on disk literally contains "seen":[4,15,22] — no decimal point anywhere, since JSON.stringify() writes GDScript ints without one. And even if the types drifted somewhere, I knew 22.0 == 22 evaluates to true in GDScript, so I assumed any comparison downstream would coerce the same way.
What actually happens
A headless script makes the drift visible (TYPE_INT is 2, TYPE_FLOAT is 3):
extends SceneTree
func _init() -> void:
var text := JSON.stringify({"seen": [4, 15, 22]})
print(text) # {"seen":[4,15,22]}
var back = JSON.parse_string(text)
var seen: Array = back["seen"]
print(typeof(seen[2])) # 3 (TYPE_FLOAT, not TYPE_INT)
print(seen) # [4, 15, 22] <- no ".0" shown!
print(22.0 == 22) # true
print(seen.has(22)) # false
print(seen.has(22.0)) # true
print(22 in seen) # false
print(seen.find(22)) # -1
print({22.0: true}.has(22)) # false
quit()
Every printed value above is what Godot 4.3.stable produced on my machine. Two things make this bug nasty. First, equality and membership disagree: 22.0 == 22 is true on the line right above seen.has(22) returning false. Second, the drift is invisible in logs — Godot prints a whole-valued float without the .0, so print(seen) shows [4, 15, 22] whether the elements are ints or floats.

The drift is also visible in the shipped save file. I opened the real one with Python, which preserves the int/float distinction of the JSON text. The two counters that pass through a coercion helper at load time — unlocked, the highest level reached, and pearls, the in-game currency — round-trip as ints; the per-level star ratings, loaded and re-saved untouched, had every value stamped as a float by the parser:
"unlocked":200,
"pearls":509,
"stars":{"1":3.0,"10":3.0,"100":3.0}
Each save/load cycle bakes the parser’s type back into the text. One field coerced, one field not — same file, two number formats.
The fix
Re-coerce every known-int field once, at the single point where the file is loaded, before any membership test can touch it:
static func as_int(v, fallback: int) -> int:
if v is int:
return v
if v is float:
return int(v)
if v is String and v.is_valid_int():
return int(v)
return fallback
static func int_set(v) -> Array:
var out: Array = []
if v is Array:
for x in v:
var n := as_int(x, -1)
if not out.has(n):
out.append(n)
return out
# at load time, right after parsing:
data["unlocked"] = as_int(data.get("unlocked", 1), 1)
data["seen"] = int_set(data.get("seen", []))
as_int() exists instead of a bare int(x) because int() on a wrong-typed value (a Dictionary smuggled in by a corrupt-but-valid-JSON file) raises a script error; a fallback keeps a damaged save from bricking the loader. The dedup inside int_set() also repairs saves already polluted by the bug: an array holding both 22 and 22.0 collapses back to a single 22. Coercing at every call site instead would mean remembering the trap forever — one choke point, and the rest of the codebase gets to assume ints again.
Why it works
JSON the format has one number type. Godot’s parser therefore maps every JSON number to a GDScript float, even when the text has no fractional part — the drift is created on read, never on write. GDScript’s == performs numeric coercion across int and float, but the container searches match values without it, so a float 22.0 in the array is unequal to the int 22 you search for. That asymmetry is why the bug survives testing: any ==-based check you write while debugging says the value is there.
This is the same genre of trap as Godot 4’s export renaming every .tres to .tres.remap: the data survives, a hidden representation change breaks the lookup, and nothing errors.
What I did not test
Only Godot 4.3.stable on Linux, desktop. I verified JSON.parse_string() directly; the game’s loader uses the JSON instance API (JSON.new() + parse()) and its save file shows the same float drift, but I did not A/B the two paths in isolation. I did not test Godot 4.0–4.2 or 3.x, typed arrays such as Array[int], or binary alternatives like FileAccess.store_var(), which may sidestep the problem entirely.
Facts
context: Godot 4 game persisting progress as JSON (JSON.stringify / JSON.parse_string)
problem: the parser returns every number as float, and Array.has(22), find(), "in" and Dictionary.has() all miss a stored 22.0 — even though 22.0 == 22 is true
solution: coerce known-int fields with int() once at load time; rebuild int arrays element by element before any membership test
verified_on: 2026-08-17
applies_to: [Godot 4.3.stable, GDScript, JSON.parse_string, Array.has/find/in, Dictionary.has]
does_not_apply_to: [the == operator (it coerces int/float), string elements, FileAccess.store_var (untested), Godot 3.x (untested)]