Two machines, one identity, and no one to ask
A Windows kiosk that speaks a CRC-checked binary protocol to slot machines over TCP, a multi-tenant cloud backend behind it, and the question of what to do when two machines claim to be the same machine.
HEADERLENCMDDATA…CRCFOOTER
The system
Three parts, built by one team.
A Windows terminal in a gaming venue, running Electron full-screen, holding open TCP connections to between one and a hundred slot machines on the local network. A cloud backend — Node, Express, Sequelize, WebSocket — handling activation, sync, remote control and billing. A React dashboard where a venue owner watches their floor without walking it.
The kiosk moves money onto machines and reads their meters back. What it prints at end of shift has to reconcile against the cash in the building.
I built the terminal and the dashboard, and worked on the backend as part of the same team.
Locking down Windows, and where that stops
The terminal runs in Electron’s kiosk mode: no frame, full screen, always on
top, hidden from the taskbar, menu bar suppressed, DevTools disabled by config
in production. A handler on before-input-event intercepts keys inside the
renderer’s input pipeline and cancels F12, the DevTools combinations, and
Ctrl+R.
That handler cannot touch Alt-Tab or Ctrl-Alt-Delete, and it is worth being precise about why.
Alt-Tab is consumed by the Windows shell before it is ever delivered to the
application’s message loop. before-input-event only sees input the browser
window actually receives, and Alt-Tab is not one of those events — there is
nothing to cancel. No amount of JavaScript reaches it.
Ctrl-Alt-Delete is the Secure Attention Sequence. Windows intercepts it below user mode, deliberately, so that no application can spoof or suppress it — malware included. It is not interceptable by any user-mode process. That is an operating system security guarantee, not a gap in Electron.
Real lockdown therefore lives outside the application, in device provisioning:
Windows Assigned Access replacing explorer.exe as the shell so Alt-Tab has no
other window to reach, Group Policy disabling Task Manager and the switcher, or
a lockdown layer applied once to the device image.
We did not wire that into the app, because it is not app code. The honest boundary is that the input handler covers in-application shortcuts, and operating system lockdown is a separate configuration concern. Anyone who tells you they blocked Ctrl-Alt-Delete in JavaScript is describing something that did not happen.
A conversation in 32 bytes
Messages between terminal and machines are capped at 32 bytes. That rules out JSON, field names, and anything self-describing.
The protocol was not handed down or reverse-engineered. I designed it jointly with the developer writing the machine-side firmware in Python, shaped by what the hardware could actually do — a very different exercise from picking a format you like.
Frames carry a header, a length, a command byte, data, a two-byte checksum and a footer. The checksum is CRC16, split low byte first, covering the command through the last data byte. Monetary amounts travel as binary-coded decimal — decimal digits packed two per byte — so the value that leaves the terminal is digit-for-digit the value that arrives. No floats, no endianness argument in the middle of a cash transaction.
TCP is a byte stream, not a message channel. Two frames can arrive in one read; one frame can arrive split across three. The header, length and footer exist so the receiver can find boundaries in a stream that has none: read to a header, take the length, consume that many bytes, confirm the checksum, confirm the footer.
A failed checksum is answered with an error naming the command that failed to parse, and the sender retries that specific message. After a 50-millisecond window and three attempts, the error is logged and shown in red to the operator rather than retried forever.
One asymmetry matters more than it looks. A machine disabled physically at the cabinet cannot be re-enabled from the terminal. The terminal sees the state and logs it, but only a person standing at the machine can clear it. Software should not be able to switch a gaming machine back on because someone tapped a button.
When two machines claim the same identity
Machines identify themselves in a polling handshake: the terminal polls every second, a machine answers with its ID, the terminal echoes it, the machine acknowledges. Only then may data flow.
But an ID is set on the hardware, by a human, with a switch. Humans set two machines to the same number. So the terminal pairs each ID with the MAC address that claimed it, and resolves collisions in three cases:
- Two unknown machines, same ID. First to connect wins and gets a record; the others are sent a disable command. The operator is told to go and change the duplicates physically.
- A collision where one machine already has a record. The MAC decides. The original keeps the ID; the impostor is disabled.
- A known ID from a new MAC, uncontested. That is a replaced or repaired unit, not a collision — the record’s MAC is updated and the machine accepted.
Throughout, the server keeps accepting connections but refuses to exchange data until the ambiguity resolves. Being unable to tell two machines apart is not an error to log and move past. It is a reason to stop moving money.
State that outlives the process
On boot the terminal loads every machine it has ever seen from local storage, before any of them connect. A machine known last week but absent today appears explicitly marked as not present, rather than silently vanishing.
Persisted per machine: in-flight transactions, meter counts, bet and play and win totals, and a timestamped audit trail of every state change — enabled, disabled, errors cleared, switches thrown, and whether each came from the terminal or from a hand at the cabinet.
Shutdown is deliberate rather than abrupt. A power-off request disconnects the machine connections, closes the TCP server, and exits — with a two-second forced exit if the server does not close cleanly, so a hung socket cannot leave the terminal running with a half-open floor.
The gap I would close first
The power-off control is rendered on the login screen, before authentication, with nothing but a yes/no confirmation in front of it. Anyone standing at the kiosk can tap it and drop out to the operating system.
There is a separate in-app screen lock, cleared by an OTP sent to email or phone, which gates navigation inside the interface. It is the right control for “step away from the register.” It is not an exit, and it does not protect the one path that actually is.
The fix is to put the same OTP or a manager PIN in front of the power-off confirmation. Given the whole point of the terminal is that the public cannot reach Windows, a bare yes/no on an unauthenticated screen is the weakest link in the lockdown — and it is in the part I wrote.
Multi-tenancy, and the same honesty applied to it
The backend models a role hierarchy on a self-referential foreign key: the platform operator sees across tenants; a client — the venue owner — is the root of their own tree; their administrators, operators and attendants hang off that root and only ever see that client’s kiosks.
The dashboard gives a client live metrics including a yield percentage calculated from in-meter and out-meter totals, kiosk detail with geocoded locations, machine listings, and two genuinely operational controls: remote lock and unlock of a kiosk, and machine enable/disable, both pushed over WebSocket straight to the terminal. That is how a venue takes a kiosk out of service without walking up to it.
Billing is metered per device rather than flat — monthly per kiosk, daily per machine, monthly for cloud — so the invoice tracks the floor.
Authorization is the weak part. Only three routes carry route-level role middleware. Everything else relies on a valid token plus per-controller tenant filtering, which is easy to get right on read paths and is exactly the pattern that eventually grows a hole on a write path someone forgets to scope. The menu hiding in the frontend is user experience, not a security boundary — it hides a link, it does not stop a direct API call. The correct fix is to centralise tenant scoping into middleware once, instead of repeating the check per controller.
Building for ten, designing for a hundred
The demo specified ten machines. The release specified a hundred. Three things changed:
Pagination on the high-volume endpoints. Machine listings and transaction history took page, limit and offset with a total count. At ten kiosks a flat fetch-everything is fine. At a hundred kiosks times their machines times their transaction history, it is not.
Exponential backoff on reconnect, capped at thirty seconds. With ten terminals a fixed retry interval barely matters. With a hundred, a backend restart means a hundred terminals retrying in lockstep on the same timer — so the delay doubles per attempt until the cap.
Sync cadence moved to configuration rather than staying a constant, so it can be tuned per deployment without a code change.
What did not change: the WebSocket registry is still an in-memory map on a single process, and the kiosk listing query is still unpaginated. Both are genuinely fine for a hundred connections and a hundred rows. Both break the moment there is more than one server instance — which is the real ceiling here, and it is a deployment topology problem rather than a code one.
Result
A hundred terminals were deployed in the United States — the full release target, not the ten-machine demo.
Whether the system is still running today, I don’t know. That is the ordinary condition of contract work: you build the thing, it ships to venues, and unless it breaks in a way that reaches back to you, you hear nothing at all.