Cheat Checks on Mobile: Server-Authoritative Racing in Godot
7 min read

BOINGWARD has a part that players cannot reach yet: an eight-player online race. The server, the protocol and the tests are written; the menu button stays hidden until the server is open to everyone. This post explains how that system was built, and its most instructive moment: in the first test on a real mobile network, all eight honest bots were flagged as cheaters at once.
The overall story is in the series hub post, and the pogo physics the bots use is in the physics post.
Who decides what?
In racing games, the core question is whose word you trust. In BOINGWARD the answer is clear: the client is not trusted.
| The server owns | The client owns |
|---|---|
| Lobby, ready, countdown, server clock | Its own pogo simulation |
| Checkpoint order and times | Input, camera, drawing |
| Finish order, time, DNF, disqualification | Smoothed display of other racers |
| Whether a respawn is valid | |
| Rewards (once per race and player) |
The client never says "I reached checkpoint 3" or "I finished". It only sends its pogo's state (position, velocity, state), and the server decides when a checkpoint or the finish was crossed, based on states that passed validation.
One more decision was made up front: in ranked races, players do not collide. Other racers are visual nodes only, with no collision bodies. You cannot expect physics to run bit-for-bit identically on different phones, and latency hits everyone differently. With collisions, network quality rather than skill would decide races.
WebSocket or ENet?
Godot 4.7 has two built-in options, and neither needs an extra plugin. In short:
| WebSocket (TCP) | ENet (UDP) | |
|---|---|---|
| Carrier NAT, corporate Wi-Fi | Works anywhere HTTPS works | Usually works, sometimes blocked |
| On the existing server | One nginx location, existing TLS; no new port | A new UDP port and firewall rule |
| On packet loss | In-order delivery; a loss holds up everything behind it | A lost snapshot is simply skipped |
The choice was WebSocket, behind nginx with TLS. The reason: it runs on the same server without opening a new port, and it does not get stuck on mobile networks. UDP's advantage is real but small for this game: other racers are drawn 0.3 to 0.6 seconds in the past anyway, and the player's own pogo never waits for the network. The race code only sees a small transport interface; switching to ENet is a config change, and both transports pass the same eight-bot test.
How movement is validated
The server compares each new state against the last one it accepted. The limits come from the pogo's own tuning file, plus a tolerance:
- Speed caps: horizontal, upward and falling speed.
- Distance and time: the path between two states cannot be longer than what can be covered in the elapsed time.
- Motion consistency: between bounces, velocity has to follow gravity and air control. The vertical error accumulates, so even a "hover" cheat that reports internally consistent velocities slowly drifts.
- Impulse budget: sudden velocity changes such as bounces, ricochets and compression draw from a small, refilling budget: 4 tokens, one more every 0.25 seconds. A real bounce uses about 2; a fly cheat that needs an impulse on every sample runs dry in about a second.
- Respawn: a teleport may only land at the start, the confirmed checkpoint, or the next checkpoint right next to it.
A violation means a strike and a correction: the player is sent back to the confirmed checkpoint. Strikes expire after 90 seconds; three active strikes at once mean disqualification. Other players always see the last accepted state, never an unvalidated claim.
The first real-network test: everyone is a cheater
The tests first passed on a single machine with simulated latency and loss. Then came a real network: the server on a remote VPS, the connection over a phone's hotspot, eight bots racing. Thirty-four seconds into the race, all eight bots got a "teleport" violation at the same moment: 3.8 to 5.2 meters in 0.18 seconds. That is more than twice the pogo's horizontal speed cap, yet every bot was bouncing honestly.
The cause was time. In the first protocol, each state was stamped with the client's estimate of the server clock. So that a cheating client could not stamp the future, the stamp was capped at "packet arrival + 50 milliseconds". On the real network, the clients' clock estimates had drifted about 0.35 seconds ahead, so every stamp hit that cap. Movement was now effectively timed by packet arrival. When TCP stalled and then released several queued states at once, the time between them was compressed, and honest hops looked like teleports.
The line in the design doc sums it up well: the cap was meant for liars and caught everyone whose clock estimate was off. Why the estimates drifted by 0.35 seconds was never proven, but the fix does not need to know.
The fix: two clocks, two jobs
In the second protocol, each state carries two separate stamps:
sim: the state's simulation clock, meaning the client's physics time. Movement is validated only on differences of this value. Network latency, queued packets, server stalls or clock-sync errors cannot stretch or compress them.time: the client's estimate of the server clock. It is only used to time checkpoints and the finish, and it is capped.
That the simulation clock should be physics time rather than wall-clock time was measured separately. Forced 0.3-second hitches were created in the game: with wall-clock stamps, 2 violations; with physics time, zero.
What if the client speeds up its own clock? Then movement would look plausible. A separate clock guard catches that: for an honest client, the gap between "arrival time minus simulation clock" rises with stalls and falls back; with a sped-up clock it keeps widening. The measurements: an honest client with stalls of up to 2.5 seconds gains zero seconds; a clock running 1.8 times fast is caught after 3.95 seconds, and 1.1 times fast after 20.8 seconds. What a cheater can still gain is written down too: at most 1.5 seconds of extra movement before the guard trips, and at most 0.35 seconds on a checkpoint time.
Results
The same field profile (TCP, client clock 0.35 seconds ahead, 150 to 400 millisecond stalls) was reproduced on a simulated clock:
| Violations | Corrections | Finished | |
|---|---|---|---|
| Protocol 1 | 24 | 16 | 0 of 8 bots |
| Protocol 2 | 0 | 0 | 8 of 8 bots |
Cheating bots raced on the same profile too: teleport, 1.8x speed, hover and 1.8x clock cheats were disqualified, the checkpoint-skipping bot was marked DNF, and honest bots took the top three without a single violation.
The second real-network test found something else: bots that failed to finish without any violation. The cause was states merged once the message limit was exceeded. After a long dropout, queued states were collapsed into one, and a checkpoint crossed inside that gap never counted. The limit was raised enough to validate about 28 seconds of backlog one by one; the doc also notes that much longer dropouts can still lose a crossing.
Platforms that move on the same clock
There was one more fairness problem: every phone loads the course at a different moment, so pistons and pendulums ran at a different phase for everyone. Other racers seemed to stand on thin air. The fix is to reset the clock of every moving piece at the moment the race starts; after that, each client's pieces advance on its own physics steps.
Some platform types (low-gravity zones, wind corridors, teleporters) break the validator's assumptions. Until the server models them, it refuses to open a course containing them for online racing; solo modes have all of them.
Frequently Asked Questions
WebSocket or UDP for a mobile game?
For a fast-reaction shooter, UDP is better. In a game like BOINGWARD, where each player drives their own character locally and sees the others slightly in the past anyway, WebSocket's reliability on carrier networks and the lack of a new port on the existing server weighed more. Keeping the transport behind an interface makes it easy to change the decision later.
Should the client be able to say "I finished"?
No. The client should only send its state; the server should decide the finish and checkpoints from states that passed validation. Otherwise a single fake message wins the race.
How do you catch speed cheats without invasive anti-cheat?
By comparing movement on the server against the limits of the physics: speed caps, distance versus time, consistency with gravity, and a budget for sudden velocity changes. Time movement with the client's simulation clock, then verify that clock itself with a separate check.
Related Posts
Pogo Stick Physics in Godot: A Character That Bounces Itself
BOINGWARD's pogo physics: auto-bounce with RigidBody3D, the launch formula, taming a snowballing speed, and a test that matches at 30, 60 and 144 fps.
Level Design from Measured Metrics, Checked by a Bot
In BOINGWARD, courses are generated from measured bounce values and a bot tries every jump with the real physics. The medal times come from that same bot's run.
Risk and Reward: Designing Air Tricks for a Pogo Game
From a one-sentence wish to a system defined by numbers: how air tricks in BOINGWARD became an optional, risky reward that does not depend on frame rate.