One grading engine, two runtimes
Making the same TypeScript produce identical verdicts in a browser tab and a Node process, and the tank that showed they only agreed by accident.
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, and for three weeks they did by accident.
The premise fits in one sentence: one engine, several places. The simulator is plain TypeScript in packages/shared with no runtime dependencies. The client imports it for live play, the Express server imports it to grade a submission, and since August the landing page's playable rung imports it too, where it replaced a hand-ported miniature solver that was always one edit away from disagreeing with the real one. There is no second implementation to drift.
Sharing the code is the easy part. Sharing the run is not.
The engine never reads the clock
A PLC timer is not a stopwatch. In the engine it is an elapsed counter that grows by whatever dt the caller passes to a scan, with presets in 100 ms units. The engine has no other notion of time, and that is enforced rather than hoped for: the linter rejects Date, performance, Math.random, window and document anywhere in packages/shared. That rule went in on the second day of the project, before there was anything to get wrong. If the engine could read the clock, a throttled background tab would be a different machine from the server.
The same idea shows up again with tasks. A program section can run on its own period, and a timer inside it advances by that period, so a K10 timer on a 200 ms task still finishes in one second, having been asked five times rather than twenty. The validator also refuses any period that is not a whole number of 50 ms scans. Otherwise the task would fire on whichever scan an accumulated remainder happened to cross, and client and server would have to agree about a rounding in order to agree about the run.
Where they did not agree
The client scanned every 60 ms. The grader stepped every 50 ms. Nobody noticed, because every machine at the time was boolean, and every process model's timings were exact multiples of both cadences. A clamp that closes after 600 ms closes on the same simulated instant whichever way you slice it.
Then the tank arrived: the first plant with a continuous state, a level that rises with the valve and drains with its own height. An integrator does not care about exact multiples. A level accumulating in 60 ms steps and one accumulating in 50 ms steps diverge immediately, and a threshold crossing lands on a different scan. A regulator that passed live could fail on submit.
The fix came in two layers. First, the client's scan interval stopped being its own number and became the grader's: one exported constant, GRADE_DT = 50, imported by the live runner, the cabinet sim, the replay and the landing page. Second, the plants stopped trusting the caller's dt at all. Every continuous model integrates on a fixed 10 ms sub-step and carries the remainder into the next call, so stepping 50 ms once and 10 ms five times gives bit-identical results. There are tests that step the same plant at both cadences and compare the whole state, and one that feeds it 35 ms on purpose, because 3.5 sub-steps is exactly where a dropped remainder would hide.
No floating point
The other way two runtimes disagree is in the last bit of a float, so the engine does not have floats worth disagreeing about. The tank level is carried in milli-counts, an integer. Transmitters report raw counts from 0 to 4000, as an FX analog card would. Registers are 16-bit signed and saturate rather than wrap, and arithmetic is evaluated at full precision and saturated on the store. Even the PID block works in integers, with its gains scaled by 100 and every term truncated.
That came in with the tank, in the same change as the cadence fix, and for the same reason: an integrator amplifies any difference it is given, and a float gives it one to amplify.
How it stays true
Every puzzle has a canonical solution, and the test suite grades each one through the same gradeProgram the server calls. Next to them are the plausible wrong answers: an open-loop valve that fails the moment the load changes, a P-only loop whose offset the tight band will not accept, a drive with no ramp parameters that refuses to start. And there are correct programs that are paced differently from the canonical one, because a grader that fails a working program for being 200 ms slower is also a grader that disagrees with the player.
What I would tell myself at the start
The shared engine is not the hard part. The hard part is everything the engine is handed: the cadence it is stepped at and the numbers it is given. Make dt one constant that both sides import, integrate on a fixed step of your own, keep every quantity an integer, and have the linter hold the line on the clock. Then "same code, same answer" stops being a coincidence of round numbers.
Skriven av Martin Dahl. Om du vill diskutera något av det här, lämna ett meddelande.