← All posts
2026-08-14
TYPESCRIPT
9 min

One grading engine, two runtimes

Making the same TypeScript produce identical verdicts in a browser tab and a Node process, and the three places it quietly did not.

AutomationSolver grades your ladder logic in the browser so you get instant feedback, and again on the server so the score means something. Both verdicts have to agree. Here is where they didn't.

The premise was simple enough that I said it out loud and immediately regretted it: one engine, two places. A player edits a rung, the browser runs a thousand scan cycles and tells them the drill station jammed at cycle 412. They submit, the server runs the same thousand cycles, and awards the work order. Same input, same code, same answer.

It held for about three weeks.

1. Map iteration order is a contract nobody signed

The simulator keeps coil states in a Map. Insertion order is specified, so I leaned on it. Then the server started hydrating its map from a JSON blob whose key order came out of SQLite, and two outputs that should have latched in the same scan latched one cycle apart. The fix is boring — sort the addresses explicitly — but the bug was invisible in every test that only checked the final state.

If two runtimes must agree, never let ordering be implicit. Sort it, or hash it, or you will find out about it from a player at 23:40 on a Sunday.

2. Timers, and the lie of Date.now()

A PLC timer counts scans, not milliseconds. I knew that. I still wrote the first version against wall-clock time because it was two lines shorter, and the browser's throttled background tabs turned a 1.0-second dwell into a 4-second one the moment a player switched to something else. The engine is now entirely deterministic: it takes a scan count and nothing else. Wall time exists only in the rendering layer, where being wrong is merely ugly.

3. Floating point, in a game about relays

There is exactly one analogue value in the whole simulation — spindle ramp-up — and of course that is where the server and the browser disagreed in the last bit, because one path went through a JSON round-trip and the other didn't. Ladder logic is boolean. The ramp is now integer permille. Problem deleted rather than solved, which is usually the better outcome.

What I'd tell myself at the start

The shared engine is not the hard part. The hard part is the boundary — everything that serializes, hydrates, or reorders on the way in. Write a fixture that runs the same fifty programs through both runtimes and diffs the full scan trace, not the verdict. I added it in month four. It found three bugs the same afternoon.

Written by Martin Dahl. If you want to argue about any of it, say hello.