Where it started
I’ve always enjoyed a good mech game starting with the RTS Total Annihilation on my ’98 Windows PC, along with others like MechWarrior, Armored Core, and Phantom Crash to name a few!
Combining my love of Mech shooters but with RPG-like mechanics I wanted to create a game that allowed for deep build expression where players can build, tune and directly pilot their bots. ImbaBots starts off with Robot Wars styled builds where the parts are things you would find from a house/garage/dump all the way up to proper futuristic mechs where experimental tech prevails.
The first commit contains no code. I wrote the design document, the milestone plan and a short file of golden rules before I scaffolded the Godot project, because the design doc names the risk the whole project lives or dies on, which is real-time melee physics over the internet. One golden rule says netcode arrives two milestones in, because retrofitting serialisation is the failure mode I wrote the rule to prevent.
With all online pvp games, the feel of the fight is hard to achieve naturally. I looked at implementing rollback, which is when the player’s inputs are run immediately and the game tries to predict the opposing player’s inputs by using the last issued input and vice versa, if correct the game goes smoothly and if not it would cause a drop in frame or a snap to the current frame after the rollback, which is visually what causes the typical lag jumps in online games. It’s the genre’s textbook answer and it assumes that replaying the same inputs reproduces the same world, which Godot’s Jolt physics makes no promise of across machines.
Implementing rollback would mean rewriting the whole simulation as a custom fixed-point deterministic core, which isn’t feasible as a solo dev. I never costed it. It was a gut call, and I didn’t see the point in writing a lot of code that could have ended in failure and had me fall back anyway. I rejected client-authoritative peer-to-peer too, since it’s trivially cheatable and leaves physics conflicts nothing can resolve. I kept that table of rejections in the design doc, and I named a fallback in advance, an async programmable autobattler, so the project had somewhere to land if the netcode didn’t hold up. It held up early and I retired the fallback unused.
The decision
The server owns the simulation, and everything else follows from what that
puts on the wire. I only ever tell a client a machine’s MachineState, a
handful of replicated floats, so no gameplay-affecting physics can live in
engine-native constraint state. A Godot hinge keeps its working numbers inside
the engine, and it’s a black box with no way to read them! They can’t go into
MachineState and a snapshot would carry nothing about the grab. Re-running
the physics wouldn’t work either, because Jolt wouldn’t provide the exact
scenario to replicate it across machines.
Every weapon has paid for that rule. The first one I created was a spinner that carries its entire dynamic state in two floats and a bool, and I compute its impulses ([ADR-0002]). The flipper was second and it modelled pneumatic energy, pressure times piston area times stroke. I had tried a literal launch impulse first and it produced tens of radians per second of spin ([ADR-0006]). Thought the machine was going to Tasmanian devil out the arena! The crusher came third as I was wanting to give some variety. It grabs and holds through a kinematic pin I compute from the attacker’s jaw frame ([ADR-0007]).
Lastly, there was the axe, a typical weapon. It looks like one but there’s no collider. It has no real physics. AxeMath resolves each strike against the machines group instead, computing energy, closing speed, impulse and hit point. Unlike the spinner which reduced the physics to variables, two floats and a bool, the axe has none, just a chop. The axe was also a testing ground for new mechanics. Due to its heavy weight class, it would force players to strip parts from the bot to accommodate it. However, it has a bonus action. It can right itself; if the bot is on its back the axe can be swung against the ground but slowly, unreliably and from certain angles. This was an attempt to balance the srimech (a self-righting mechanism component), which performs a similar save function.
No Joint3D exists anywhere in the game, by design. The only ones in the repo are in the spike that ruled them out. The test harness follows the same rule. Latency is a deterministic message-queue shim whose delivery schedule is a pure function of profile and seed, because real sockets would put OS scheduling into the evidence ([ADR-0003]).
What it buys
Now I can state the hit-registration bar and hold it. At least 95% of strikes that visually connect on the attacker’s screen must register at 100 ms round trip with jitter and loss, and with zero hard prediction snaps. Bounded trust is how I got there. The client reports what it perceived, the server honours the report only inside a hard data-defined bound, and the server keeps all damage authority ([ADR-0004]). Every mechanism in that ADR exists because a measurement demanded it, including one candidate I tried, measured and reverted. The bar has held at 100% with zero snaps through every milestone since, and it runs as a gate on any task that touches it, which is why it’s never silently regressed. Nearly half of the GDScript in the repo is tests for that reason.
The rule also caught its own oldest code. I wrote a bit-identical replay gate before the feature even needed it, pointed it at the shipped wheel drive, and it failed. The replayer had been quietly reading live node state for five whole milestones. Made me wonder how many things I could have caught if this was working correctly in the first place!
Where it’s going
With tier 2 bots, I am implementing legs, and the physical joints of the quad are nothing more than an engine state, so I measured it before building. Like the saying goes measure twice, cut once. The verdict arrived on day two of a fifteen-day box. I ran a settled nine-body rig with its motors off twice from identical setup in two fresh arenas and it landed 0.14mm apart. That was the control, there to rule out the simulation simply being chaotic. Nothing was moving yet it still drifted, so the drift is in the engine itself. Restoring the chassis on its own, which is all a MachineState snapshot would carry, put the rig 0.765m and 128° out, which is a robot standing somewhere else facing a different way. Godot 4.7.1 exposes no Jolt setting for deterministic solving, so no amount of state capture fixes it and rollback’s premise fails before rollback is even attempted. The fallback I had named in advance was done in a day, treating a leg as a wheel whose contact point stops moving. It stays one rigid body with no joints, and I measured its replay divergence at exactly zero across every case, even with a destroyed leg.
To close the milestone off, I am currently working on how free aim works and feels. Playing it in person to have a proper human read on it. The full Tier 2 catalogue follows, and then the hard part creating the infrastructure to serve online play.