16 September 2026
PixelTrader writes its whole floor to one JSON file: every broker's cash, holdings, cost basis, strike count, the risk baselines, the benchmark. On launch it reads that file back and the floor resumes.
On 15 September, in real money mode, it didn't. The floor came back empty and eight brokers restarted the day dividing up what looked like a fraction of the account. The positions were still sitting at the brokerage. The game had no idea they existed.
with open(STATE_FILE, "w") as f:
json.dump(state, f)
That is the bug, in full.
open(path, "w") truncates the file the instant it opens
— before json.dump has written anything. For however long the
dump takes, the file on disk is empty or partial. Anything that interrupts it
leaves exactly that behind: a crash, Task Manager, the machine sleeping, an
exception halfway through serialising.
There was a try around it. It caught the error faithfully and
printed a message. By then the previous save had been gone for milliseconds. An
exception handler cannot un-truncate a file.
The part I'd want someone else to take from this: I had thought about save failure. That is what the try/except was for. What I had not thought about was that the old save is the thing at risk, not the new one. Writing in place puts the only good copy inside the blast radius of the operation that might fail.
Two things downstream turned a lost file into a bad state.
The loader treated any failure as a new game. A blanket
except printed a note and returned round one. In simulation that is
a fine trade — you lose a practice run. In real mode it means opening a
floor with no memory of positions that still exist at the broker, and every risk
rail in the program only evaluates holdings it can see. The day-loss stop and
the account breaker were running perfectly against an empty book while real
money sat outside them.
Re-seeding read the wrong number. When the floor started over it asked the brokerage for a balance and split it eight ways. The function it called returns the account's cash field, not its equity. With most of the money in positions, cash is roughly whatever reserve is left over — which is why eight brokers woke up sharing a sliver of the account. That function had never been wrong before, because at genuine first funding cash and equity are the same number. It is only ever wrong on a re-seed, which is only reachable after the failure above.
Write somewhere else, then swap:
with open(tmp, "w") as f:
json.dump(payload, f)
f.flush()
os.fsync(f.fileno())
if os.path.exists(path):
shutil.copy2(path, path + ".bak")
os.replace(tmp, path)
Three things are doing work here.
The temporary file means the real save is untouched until a
complete new one exists. fsync forces the bytes
onto the physical disk rather than leaving them in an operating system buffer
that a power cut would discard — without it the tmp file can be as
incomplete as the thing we are trying to avoid. And os.replace
is atomic on Windows and POSIX alike: the path resolves either to the old file
or the new one, with no instant in between where it is neither.
The .bak copy is one generation back, which costs nothing and is
the difference between losing a session and losing the floor.
It now tries the main file, falls back to the backup, and if neither can be read it keeps a timestamped copy of the damaged one rather than leaving it to be overwritten. In simulation it starts fresh as before. In real mode it refuses to open the floor at all, with a message saying to check positions at the brokerage first.
A floor that will not open is a problem you can see. A floor that opens having forgotten what it owns is one you cannot. That is the whole argument for failing closed here, and it is the opposite of the call I made on the single-instance guard — where refusing to start was the damaging answer. The difference is what the failure tells you. A mutex that cannot be acquired says nothing about whether a second copy is running. A save that cannot be read says precisely that the game's picture of your money is gone.
Nine cases, run against real files on disk: normal write, backup rotation,
read-back, corrupt main with intact backup, both corrupt in simulation, both
corrupt in real mode, quarantine behaviour, empty directory, and a write
deliberately made to throw partway through json.dump.
That last one is the whole bug reproduced on purpose. Before the fix it leaves a truncated file. After it, the previous save still parses and still holds the right round number.
One deliberate detail: the damaged save is copied aside, not moved. New Game deletes the save by path, and renaming it would break the one route a player has out of the situation. When this happened to me I deleted the corrupt file before reading it, which cost the only evidence of what had actually gone wrong.