RKT.BTL - A physics based rocket battle game

This page talks about the physics I derived for a "homemade" (artisanal physics, Claude-assembled) game. Two rockets, each with two on/off boosters, try to kill each other and land on their enemy's platform. Give the game a try below! If you're interested, the source code can be found on GitHub.

RKT.BTL gameplay
Two rockets, two floating pads. Land on the other rocket's pad before its AI lands on yours.
← → or A D boosters · both = straight thrust · R = rematch · opens full screen, Esc to close

Intro

I have always liked physics based games. Sim racing, Rocket League, Trackmania, pretty much any kind of simulator. As I ramp up on RL, I had the idea of building my own physics based game and letting an RL policy find something cool to do in it. Coming from aerospace, and since it had been a long while since I last did any real dynamics derivation, I settled on a rocket based game.

I wanted it to be both fun and challenging, so that a human could enjoy playing it and an RL policy could pull off some impressive maneuvers. I also wanted it to run either in the browser or through a Python binding.

I did not have the time to code all of it from scratch, and I am missing some of the knowledge to do it well, mainly the web and Python hooks. I am also at a point in my career where I don't really "code for fun" anymore. A lot of what I build lately rhymes with something I have already done, so the pull to implement it just to learn isn't really there. So I was happy to let Claude handle the implementation while I derived the physics engine on paper.

For the engine I went with Rust compiled to a WebAssembly API. I had wanted to learn Rust for a while, and having Claude write it while I reviewed code I could actually follow turned out to be a nice way in.

This page discusses the physics behind the game: the 2D rigid-body dynamics, the penalty-spring contact for the legs, the impulse-based rocket-rocket collisions, and the integrator that ties them together.

Game goal

There is a single win condition, and it has two halves: you have to land on the enemy's pad, and the opponent has to be dead. How the enemy went down does not matter. A landing counts once you have held stable on the enemy platform for at least one second. Anything else is a loss, including both rockets destroying each other.

Fuel is limited, and a rocket burns a small amount at a constant rate the whole time. That steady drain doubles as a timer, it keeps you from sitting still and pushes you to actually go do something.

Each rocket comes in two parts: an indestructible top that can take any shock, whether from the environment or the other rocket, and a fragile bottom that destroys the rocket if it is hit hard enough. A hard hit on the top half can still knock you off balance and send you into a spin you cannot save. Part of the game is learning to knock the enemy around without losing control of your own ship.

Landing stands deploy as you approach a pad to make touching down easier.

You fly with two buttons: the left arrow (or A) fires the left booster, the right arrow (or D) fires the right, and holding both gives straight-up thrust. The sim runs at a fixed 60 Hz.

Free-flight dynamics

The rocket is a 2D rigid body with three degrees of freedom: position $(x, y)$, tilt $\theta$ from vertical, and their rates $(\dot x, \dot y, \dot\theta)$. Newton's law for the center of mass and Euler's law for rotation about it govern the state,

$$ m\,\ddot x = \textstyle\sum F_x, \qquad m\,\ddot y = \sum F_y, \qquad I\,\ddot\theta = \sum \tau, $$

with mass $m$ and moment of inertia $I$. Under free flight conditions (no contact) the only forces acting on the booster are thruster forces and gravity. We assume the rocket is in space and ignore aero.

Gravity acts at the center of mass with $mg$. Each booster is binary, with forces $T^l$ and $T^r$, so the total thrust is $T = T^l + T^r$. By binary, we mean each thruster is either fully on or fully off. We resolve all dynamical equations in a fixed inertial frame, with $x$ horizontal and $y$ up. With a tilt of $\theta$, the components of the total thrust in that frame are

$$ T_x = -T\sin\theta, \qquad T_y = T\cos\theta, $$

and Newton's law gives the translational acceleration:

$$ m\,\ddot x = -T\sin\theta, \qquad m\,\ddot y = T\cos\theta - mg. $$
Free body diagram: gravity mg at the center of mass, two booster thrusts T^l and T^r offset by d, body tilted by theta Free body diagram: gravity mg at the center of mass, two booster thrusts T^l and T^r offset by d, body tilted by theta
Free body diagram. Gravity $mg$ acts at the center of mass; the boosters $T^l, T^r$ are offset a distance $d$ from the centerline. The thrust differential between the left and right booster is what creates a moment and eventually lateral motion.

The boosters sit $\pm d$ off the centerline, so an imbalance torques the body about its center of mass, and Euler's law $I\,\ddot\theta = \tau$ gives the angular acceleration:

$$ \tau = (T^r - T^l)\,d, \qquad \ddot\theta = \frac{(T^r - T^l)\,d}{I}. $$

Because of the binary thrust inputs, there is no constant control input that would keep the system at a steady state (hovering or constant-speed flight). For that reason it is a limit cycle that requires constant user input to keep up.

Landing

Landing is broken down into two subproblems: a vertical normal force reaction, and a lateral friction from the contact between the leg and the pad.

Normal force determination

Recall that the landing legs deploy from the rocket as it approaches the pad. For each endpoint of those legs we approximate the normal force using a spring-mass-damper system, as illustrated below. This is somewhat similar to a simplistic suspension system. With superscripts $l, r$ for the left and right foot, $\delta^r$ is the difference between the right leg endpoint's $y$ coordinate and the platform's $y$ coordinate, positive as the leg goes through the platform, and the normal force there is

$$ F_n^r = \max\!\big(0,\; k\,\delta^r + c\,\dot\delta^r\big), $$

with the left foot $F_n^l$ identical in its own penetration $\delta^l$.

Spring-mass-damper schematic: mass m on a spring k and a damper in parallel, with penetration delta Spring-mass-damper schematic: mass m on a spring k and a damper in parallel, with penetration delta
Spring-mass-damper contact. A leg endpoint against the deck is modeled as a spring $k$ and a damper in parallel: penetrate the deck by $\delta$ and the foot pushes back with $F_n = \max(0,\, k\delta + c\dot\delta)$, clamped non-negative so it pushes but never pulls.

In simulation the leg is allowed to travel through the pad, but only slightly, as the normal force will quickly prevent any strong violation.

The value of the spring stiffness determines how "rigid/stiff" the response is, while the damper removes energy in order to prevent oscillations. One thing to be on the lookout for is the value of the spring stiffness. The general rule of thumb says that for a given simulation step $\Delta t$, $k$ should not exceed $4m/\Delta t^2$ (about $2.6\times10^4$ at $\Delta t = 1/60\,\mathrm{s}$), at the risk of causing push-back that would send the rocket flying in the event it clipped through the platform too much ($\delta$ is non-negligible).

Lateral friction

The second piece is friction where each leg endpoint slides on the deck. We use a simple friction model to represent the lateral interaction between the leg and the landing pad. Friction opposes the foot's sideways velocity $v_x$ and saturates at the friction cone (bounded by the normal force available). The superscripts $l, r$ label the left and right foot, so for the right one

$$ F_f^r = \operatorname{clip}\!\big(-c_t\,v_x^r,\; -\mu F_n^r,\; +\mu F_n^r\big), \qquad |F_f^r| \le \mu F_n^r, $$

and the left foot $F_f^l$ is the same with its own $v_x^l$ and $F_n^l$. $\mu$ is the coefficient of friction and $c_t$ is a viscous term. Friction is linear in slip (which in our case is just $v_x$ since the platform is static) and clamped at $\mu F_n$ where $F_n$ is determined by the "suspension" system above. So the more normal force available, the more friction is available.

Each foot force (normal and friction) acts at a lever arm $(r_x, r_y)$ from the center of mass, so it also makes a moment. A foot planted off to one side both holds the rocket and rotates it, which is why a lopsided or one-legged touchdown spins you.

Free body diagram of a foot contact: normal force pushes up and friction acts sideways at the foot, with lever arms r_x and r_y from the center of mass Free body diagram of a foot contact: normal force pushes up and friction acts sideways at the foot, with lever arms r_x and r_y from the center of mass
Foot forces. At a planted foot the normal $F_n^r$ pushes up and friction $F_f^r$ resists sideways slip. Acting at the lever arm $(r_x, r_y)$ from the center of mass, the pair also torques the body, which is why an off-center touchdown both holds and spins the rocket.

The legs handle the soft touchdown on top, each foot a spring-damper against the deck, and a landing registers once the rocket is slow, near upright, barely spinning, with both feet planted on the target pad for about a second.

Collecting the contact terms, the equations of motion during landing extend the free-flight balance with the two feet's normals, frictions, and the moments they make:

$$ m\,\ddot x = -T\sin\theta + F_f^l + F_f^r, \qquad m\,\ddot y = T\cos\theta - mg + F_n^l + F_n^r, $$ $$ I\,\ddot\theta = (T^r - T^l)\,d + \sum_{i\,\in\,\{l,\,r\}} \big(r_x^i\,F_n^i - r_y^i\,F_f^i\big). $$

Collision handling

The two rockets are rigid and impenetrable. Rather than resolve the elastic deformation of a real collision, I model the whole contact as a single instantaneous impulse, the standard treatment for rigid bodies. I haven't done any type of impulse dynamics since undergrad so I was excited to learn about it again.

A collision is a large force over a vanishing time; only its time integral matters, the impulse $J = \int F\,dt$. Integrated across the instant, positions are frozen and velocities jump:

$$ m\,\Delta v = J, \qquad I\,\Delta\dot\theta = r \times J. $$
Where does this come from? Both equations are Newton's laws integrated through the impact. The first is the linear law, $\int F\,dt = \int m\,\dot v\,dt = m\,\Delta v$. The second is its rotational twin: $\int (r \times F)\,dt = r \times \int F\,dt = r \times J = I\,\Delta\dot\theta$. The changes in linear and angular momentum are therefore just $J$ and $r \times J$.

The rocket's pose $(x, y, \theta)$ is assumed to remain constant during the impact (nit: we actually do slightly adjust the position for clipping reasons, more on that below) and only velocities $v$ and $\dot\theta$ change in a discontinuous way. I never have to model what the hulls do during contact, the squish and the contact-patch forces, just the single before/after velocity jump the impulse captures. super cool!

So the collision reduces to one scalar $j$, the size of the kick. This is the standard rigid-body impulse method apparently used by many 2D simulators including Box2D.

Contact geometry

Each hull is a capsule: a centerline segment inflated by a radius $\rho$. Let $q_A, q_B$ be the closest points of the two centerlines and $d = \lVert q_A - q_B \rVert$ their distance. The contact normal points from $B$ toward $A$ along that line,

$$ \hat n = \frac{q_A - q_B}{\lVert q_A - q_B \rVert}, $$

and the hulls overlap when $d < 2\rho$.

Two capsule hulls touching: closest points q_a and q_b on the centerlines, contact point p, contact normal n-hat, equal and opposite impulses +J and -J, capsule radius rho Two capsule hulls touching: closest points q_a and q_b on the centerlines, contact point p, contact normal n-hat, equal and opposite impulses +J and -J, capsule radius rho
Two hulls touching. Each hull is a capsule of radius $\rho$; $q_a, q_b$ are the closest points of the two centerlines and $p$ the contact point. The impulse acts along the normal $\hat n$, equal and opposite ($+J$ on one rocket, $-J$ on the other), so momentum is conserved.

The lever arm created by that contact point can be expressed for both rocket systems as:

$$ r_A = p - x_A, \qquad r_B = p - x_B. $$
Why a capsule? For the general case of 2 polygons colliding there is no single direction perpendicular to both surfaces. The normal is generally a body's face normal against the other's vertex, while edge-edge and vertex-vertex cases need special handling. The big advantage of using a capsule is that it makes finding the normal trivial: just find the vector between the contact point $p$ and the closest point on the line segment.

Relative velocity at the contact

The material point of each body at $p$ carries both translation and spin. In 2D, $\dot\theta \times r = \dot\theta\,(-r_y,\, r_x)$, so the contact-point velocities are

$$ v_{A,p} = v_A + \dot\theta_A\,(-r_{A,y},\, r_{A,x}), \qquad v_{B,p} = v_B + \dot\theta_B\,(-r_{B,y},\, r_{B,x}). $$

The impulse acts on the normal component of the relative contact velocity,

$$ v_n = (v_{A,p} - v_{B,p}) \cdot \hat n, $$

and fires only when the bodies are approaching, $v_n < 0$.

The impulse and its effect

Apply $J = j\,\hat n$ to $A$ and $-J$ to $B$. From $m\,\Delta v = J$ and $I\,\Delta\dot\theta = r \times J$, each body's velocities jump by

$$ \Delta v = \frac{j}{m}\,\hat n, \qquad \Delta\dot\theta = \frac{r \times J}{I} = \frac{j\,(r \times \hat n)}{I}, $$

where $r \times \hat n = r_x n_y - r_y n_x$ is the scalar 2D cross product. The contact point therefore moves by

$$ \Delta v_p = \frac{j}{m}\,\hat n + \underbrace{\Delta\dot\theta\,(-r_y,\, r_x)}_{\Delta\dot\theta \,\times\, r}. $$

Project onto $\hat n$. The linear term gives $\hat n \cdot \hat n = 1$. The angular term becomes

$$ (-r_y,\, r_x) \cdot (n_x, n_y) = r_x n_y - r_y n_x = r \times \hat n, $$

So a single body's contact-normal speed changes by

$$ \Delta v_n = j\left( \frac{1}{m} + \frac{(r \times \hat n)^2}{I} \right). $$

The $(r \times \hat n)^2 / I$ term is the share of the kick that affects the spin. It vanishes for a dead-center hit ($r \times \hat n = 0$, pure billiard-ball behavior) and grows with the lever arm, which is why off-center hits really yeet that rocket (forgive me if you read that).

Solving for the magnitude

Sum the change over both bodies and impose Newton's restitution law (never heard about it before and find the name quite cool, learn more here!) $v_n' = -e\,v_n$ where $v_n'$ is the post-collision relative velocity. That is $\Delta v_n = v_n' - v_n = -(1+e)\,v_n$. Solving for the one scalar $j$ gives:

$$ j = \frac{-(1+e)\,v_n}{\dfrac{1}{m_A} + \dfrac{1}{m_B} + \dfrac{(r_A \times \hat n)^2}{I_A} + \dfrac{(r_B \times \hat n)^2}{I_B}}. $$

The denominator is the effective inverse mass along $\hat n$; dropping the two rotational terms leaves the reduced mass $\big(\tfrac{1}{m_A} + \tfrac{1}{m_B}\big)^{-1}$ of two sliding point masses. With equal bodies ($m_A = m_B = m$, $I_A = I_B = I$) it reduces to exactly the code's

$$ \texttt{denom} = \frac{2}{m} + \frac{(r_A \times \hat n)^2 + (r_B \times \hat n)^2}{I}, \qquad \texttt{jimp} = \frac{-(1+e)\,v_n}{\texttt{denom}}. $$

The restitution $e \in [0, 1]$ is a tunable parameter on how "bouncy" you want the impact to be:

$$ v_n' = \begin{cases} 0 & (e = 0)\ \ \text{maximum energy loss, the normal relative velocity goes to zero} \\[4pt] -v_n & (e = 1)\ \ \text{full energy conservation, very bouncy, both rockets feel the full brunt of the collision} \end{cases} $$

When a hit wrecks the engine

The bounce just stops the hulls overlapping; destruction is a separate test. Only the bottom (engine) half is fragile, the top is indestructible. To find which half was hit, I project the contact point onto the hull's centerline as $t \in [0, 1]$, from engine ($t = 0$) to nose ($t = 1$). It's the fragile half when $t < 0.5$.

A soft graze shouldn't wreck it, so a kill needs both conditions:

$$ \text{destroyed} \iff (t < 0.5) \;\wedge\; (-v_n \ge v_{\text{crush}}), $$

where $-v_n$ is the closing speed and $v_{\text{crush}}$ a tunable threshold. So you have to ram your nose into their engine hard; anything softer, or a hit on the nose, just bounces.

Keeping rockets from overlapping

The impulse fixes velocities but not geometry. On a fast hit the two hulls can already be interpenetrating at the end of a step, and cancelling the closing velocity does not pull them back out: an overlap that already happened just sits there. So I add a second, purely positional correction.

For capsules the penetration depth is exact. Reusing the centerline distance $d = \lVert q_A - q_B \rVert$ from above, the overlap along $\hat n$ is

$$ \delta = 2\rho - d, $$

and I split it evenly, sliding each center half the depth along the normal:

$$ x_A \mathrel{+}= \tfrac{\delta}{2}\,\hat n, \qquad x_B \mathrel{-}= \tfrac{\delta}{2}\,\hat n. $$

A rigid translation moves every point of a body equally, so sliding the centers apart by $\delta$ separates the contact points by exactly $\delta$, no rotation needed. The even split is a simplification but it works well enough since rockets are of the same mass.

Collision with a platform

The pads collide by the same capsule logic, with one change: a pad is immovable. It takes no impulse and no positional push, so the full bounce and the full overlap correction go to the rocket, sliding it out by the whole $\delta$ instead of $\delta/2$. A hard engine-first landing on a pad still wrecks you.

Numerical integration

Everything advances on a fixed step $\Delta t = 1/60\,\mathrm{s}$ with semi-implicit (symplectic) Euler: update velocity from the current acceleration, then position from the new velocity,

$$ v_{t+1} = v_t + a_t\,\Delta t, \qquad x_{t+1} = x_t + v_{t+1}\,\Delta t. $$

The only change from explicit Euler is using $v_{t+1}$ rather than $v_t$ in the position line. It costs the same, but it is symplectic: it conserves a nearby shadow energy instead of drifting, so oscillations stay bounded (more here). Plain explicit Euler injects energy every step, which on the stiff legs could throw the rocket off-screen.

Tuning

The physics model contains a lot of tuning parameters. To simplify the tuning I relied on two effective parameters, the thrust-to-weight ratio and the angular authority, both shown live in the game.

The first is the thrust-to-weight ratio. Both boosters at full give $2\,t_{\max}$ of lift against a weight $mg$, so

$$ \frac{T}{W} = \frac{2\,t_{\max}}{m g}. $$

Anything above 1 can hover, and the higher it goes the harder it climbs. I kept it around $4.5$ so it climbs hard but you can still feather it down.

The second is the angular authority, how quickly it can rotate. One booster at full sits a distance $d$ off the centerline, so it makes a torque $t_{\max} d$, and dividing by the moment of inertia gives the peak angular acceleration,

$$ \dot\theta_{\max} = \frac{t_{\max}\, d}{I}. $$

That is the twitchiness. Too low and it can't straighten up in time to land, too high and it spins out on the smallest tap.

I also had to move the center of mass down. With the boosters at the base and the feet hanging below the CoM, a touchdown acts like an inverted pendulum, where the higher the CoM sits above the feet, the more it wants to topple. Lowering it toward the engines shortens that lever arm and makes the landings much calmer. Moving the CoM off the geometric center also feeds into the inertia through a parallel-axis term,

$$ I = i_{\text{scale}}\, m\left(\frac{w^2 + h^2}{12} + e^2\right), \qquad e = (\mathrm{com} - 0.5)\,h, $$

so a lower CoM adds a little rotational inertia and takes some of the twitch away.

After flight testing the game a bunch I ended up with the following set of params, which I thought made the game both fun and challenging.

parametervalue
mass $m$$1.40\,\mathrm{kg}$
inertia scale $i_{\text{scale}}$$2.95\times$ plate
CoM (engine→nose)$0.30$
body $w \times h$$0.48 \times 1.40\,\mathrm{m}$
thrust / booster$29\,\mathrm{N}$, $\;T/W \approx 4.6$
restitution $e$$0.35$
crush speed$3\,\mathrm{m/s}$
explode speed$5\,\mathrm{m/s}$
leg spring $k$ / damper $c$$1000$ / $50$
hull radius $\rho$$0.24\,\mathrm{m}$
fuel$100$, burn $6.15/\mathrm{s}$ per booster

Almost all are tunable in the game, and I recommend playing around with them, it's fun :-) My kids love min/maxxing all values and have a completely whacko rocket impossible to fly around. One important note is that future RL-based AI will heavily be tied to these values, so I don't recommend changing them too much if flying against AI.

Opponent AI V0

In order to test the game I added a very simple AI model that tries to fly to the opponent's pad. For this I asked Claude to design a "cascaded PD type" controller and was amazed by the final result. It's a simple model but the architecture was sound. Try to knock it out and see it struggle, it's fun!

There will (hopefully shortly) be more advanced AI, with RL policies, and I look forward to designing them and talking about it in a future note!

Terminal Reward

If you made it all the way down here, you have earned a launch. Run ./liftoff.sh in the terminal on the homepage.

Cya!