The idea is a parking game, and parking is the slowest thing a car does: pulling away on the clutch, rolling at 5 km/h, turning the wheel all the way, backing up. A car that just “goes where it points” will not do. Before thinking about spaces and cones, I wanted a car with the parts that make a manual car hard to maneuver.
The project is C++23 with raylib for the window and drawing, and Dear ImGui for a debug panel with everything the physics computes. The skeleton (CMake with presets, dependencies through FetchContent, Clangd) came from cpp-gen, the C++ project generator I maintain. I will follow the commits in order, because each one fixed a limitation of the one before.
From pedal to wheel
The first commit built the car as a chain of parts, each in its own class: Engine, Clutch and Gearbox. Torque goes through them in the same order as in a real car:
- Engine. Torque follows a bell curve peaking at 350 Nm at 4,500 rpm, never below 10% of the peak, multiplied by the throttle. Internal friction is taken off it and grows with rpm, from 15 to 65 Nm. With your foot off the throttle the torque goes negative, and that is what slows the car when you lift (engine braking).
- Clutch. The pedal sets how engaged it is, from 0 to 100%. Its capacity is 600 Nm times the engagement: more torque than that only gets through up to the limit, and the rest is the clutch slipping.
- Gearbox. Five gears (3.5, 2.0, 1.4, 1.0 and 0.8), reverse at −3.0 and a 3.4 final drive that multiplies them all. It only shifts with the clutch pressed.
- Wheel. Axle torque divided by the wheel radius (0.33 m) becomes force. That force is capped by the rear tire’s grip (1.1 times the weight on the axle); past it, the wheel spins and only 70% reaches the ground.
The pedals are not buttons either. The throttle rises at 2 per second and falls at 4, the brake rises at 3 and falls at 5, so holding the arrow key for half a second is half a pedal. It looks like a detail, but it is what lets you meter a launch from a keyboard.
The part that taught me the most was engine rpm. It is not an independent number: with the clutch engaged, the engine turns at the speed the wheels and the gear impose; with it released, it revs freely, rising with the throttle and dropping 3,500 rpm per second without it. In between, the two are blended by the engagement:
// Blend free-revving RPM with drivetrain-constrained RPM based on clutch engagementif (!is_neutral && clutch_engagement > 0.01F) m_current_rpm = Lerp(free_rpm, target_rpm, clutch_engagement);else m_current_rpm = free_rpm;target_rpm comes from the car’s speed: wheel rpm times the gear ratio times the final drive. When the wheel spins, the clutch counts as released in that calculation, and the engine revs up like a real car spinning its rear wheels.
Pulling away without stalling
A stopped car in gear has its wheels at 0 rpm, so the calculation above would drag the engine to zero. So that you do not need a perfect left foot on a keyboard, below 3 m/s (10.8 km/h) the car works the clutch on its own: engagement is how far rpm is above idle, (rpm − 1,000) / 1,000. The engine revs, engages a little, gets pulled down, and settles.

Past 3 m/s the clutch engages fully and the engine is locked to the wheels. Then the car really goes: in about 6 seconds of 1st it reaches 67.8 km/h, at 6,800 rpm.


In the screenshots the car goes past 134.6 km/h in 2nd and 155.9 km/h in 3rd. In this version speed was a single number: the car went where it pointed, and turning the wheel only rotated the drawing. For parking, that is not enough.
Tires that slide: Pacejka
The second commit replaced that number with vectors. Velocity now exists in two frames: the world’s, to move the car on screen, and the car’s own, with a forward component and a sideways one. The car also gained a rotation speed (yaw rate) and a moment of inertia.
With a sideways velocity, each axle has a slip angle: the difference between where the tire points and where it is actually going. That angle produces the lateral force that makes the car turn, and the relation is not linear. Up to a point, more angle means more force; past it, the tire saturates and starts to slide. The most used formula for this is Pacejka’s “magic formula”, with four fitting coefficients:
float Car::CalculatePacejka(float slip_angle, float normal_load){ float B = TIRE_B; // 1.5: stiffness float C = TIRE_C; // 1.9: curve shape float D = normal_load * TIRE_D; // peak, proportional to the load on the tire float E = TIRE_E; // 0.97: curvature past the peak return D * sinf(C * atanf((B * slip_angle) - (E * (B * slip_angle - atanf(B * slip_angle)))));}The front and rear lateral forces go into the sum of forces and into the yaw torque (the front force times its distance to the center, minus the rear one times its own). A tight corner at high speed now makes the car slide sideways and lose speed, instead of turning as if on rails.

The slip angle is computed with atan2 of the sideways speed over the forward speed, and near zero that is unstable: a tiny forward speed gives huge angles. So below 3 m/s with the wheel turned, the yaw comes from a bicycle model, pure geometry: the car rotates around a radius equal to the wheelbase divided by the tangent of the steering angle. It is the same range as the automatic clutch, and it is the range you park in.
Weight that moves
The third commit brought load transfer. Accelerating, weight goes to the rear; braking, to the front. How much depends on the mass, the acceleration, the height of the center of gravity (0.55 m) and the wheelbase. Each axle’s weight now feeds two things: that axle’s Pacejka peak and the rear wheel’s traction limit.
Two decisions are written down in the code itself. The calculation uses the previous frame’s acceleration, because this frame’s depends on forces that depend on the weight: it would be a circular dependency. And each axle keeps at least 20% of the weight, because with no weight the Pacejka peak goes to zero and the lateral force vanishes at once.
Splitting before growing
The fourth commit did not change behavior: it split Car.cpp, which was past 550 lines, into three files. Car.cpp kept the lifecycle, input and getters, CarPhysics.cpp took pedals, drivetrain, forces and integration, and CarRendering.cpp took geometry and drawing.
The drawing side holds the Ackermann geometry. In a turn, the inner wheel travels a smaller circle than the outer one, so the two cannot share the same angle, or one of them scrubs. Each front wheel gets the angle that points at the same turning center, and the red lines show it.

What the screenshots revealed
To take the screenshots, I drove the car through a script: a main just for this, outside the repository, that presses keys at set frames using raylib’s own automation events and saves the screen together with the panel’s telemetry. Repeating the same drive showed two bugs I had not seen while playing.
Pressing the clutch below about 15 km/h stops the car instantly. At 11.1 km/h, three frames after pressing the clutch, the speed is zero.


The cause came from the Pacejka commit. The car had a full-stop rule so it would not creep at 0.001 m/s forever: below 0.05 m/s and with no drive force, speed becomes zero. In the refactor, the rule started checking acceleration instead of velocity. With the clutch pressed only drag and rolling resistance are left, and up to about 4.1 m/s (15 km/h) they slow the car by less than 0.05 m/s². The fix is to compare velocity again.
With the wheel at full lock, the car does not move. Throttle floored, 1st gear, and it sits at 0.7 km/h. The bicycle model handles yaw at low speed, but the front tire force is still computed, with a slip angle close to the steering angle (40°). That gives about 5,700 N of lateral force, and the part of it pointing against the motion (about 3,600 N) is more than the engine delivers with the automatic clutch at 5% (about 1,000 N). In a parking simulator that is exactly the main maneuver, so it is the next commit.
One last note for anyone opening the repository: the latest commit replaces the car on screen with a square that moves with the arrow keys, and the car drawing and the panel are commented out in main.cpp. The screenshots in this post come from the commit before it, the last one with the whole car.
What I learned
- Model the whole chain, not the result. Speed as the output of engine, clutch, gearbox and tire makes behaviors I never programmed show up on their own: engine braking, revs rising during a shift, a spinning wheel.
- Know where a model breaks and switch models there. Pacejka is great in motion and unstable near zero, so low speed uses geometry.
- A refactor that “changes nothing” changes something. The stop rule swapped velocity for acceleration when it moved, and only a repeated drive showed it.
- A panel with every number the physics computes is worth more than a pretty drawing. Both bugs showed up reading the panel, not watching the car.
The code is on Codeberg. My personal repositories live there, off GitHub, so they do not depend on a single platform.