The save file was destroyed by the code meant to save it

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.

The line

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.

What made it worse

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.

The fix

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.

And the loader stopped guessing

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.

Testing it

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.

Previous: The single-instance guard worked perfectly and bricked the game

All devlog posts