Far too harsh on the keyboard — the ebike's steering had no smoothing
The game has a delivery ebike you can walk up to, mount and ride across the open-world town. It exists because the map got big. Walking everywhere stopped being pleasant, the car is a heavier thing to get in and out of, and a bike sits neatly in between — quick, cheap, parks anywhere, and it suits a character who does delivery work.
This entry is about the day I tried to make it feel decent to ride, and about being straight regarding what that day actually proved. Which is: less than it sounds.
What the bike is, underneath
It is not a physics vehicle. The car in this project uses the engine's proper vehicle simulation; the bike is hand-rolled and kinematic. Every frame it line-traces downward from a little above itself, snaps its origin to the ground hit plus a fixed 60 cm, sphere-traces forward for walls, and integrates its own speed from pedal input, braking, rolling drag and a slope multiplier.
I chose that deliberately, and it has one property I keep being glad of: the behaviour is exact rather than emergent. Because the wall check is a radius-30 sphere swept forward from an origin at ground plus 60, its lowest point sits at ground plus 30, and it only blocks on surfaces steeper than about 53°. So the traversal rule has a number in it: a near-vertical step of 30 cm or less is ridden straight over, anything taller is a wall, and there is no "it depends how fast you're going". I can go and measure a kerb and know the answer before anyone rides at it. With a physics vehicle I would be guessing.
The trade is that everything a physics solver would give you for free — weight transfer, grip, the feel of the thing — I have to write myself. Which is where the trouble was.
The method
This is a Blueprint-only project driven almost entirely through two editor automation servers: one for Blueprint graphs, assets and actors, one for the rest. Graphs get authored as text and spliced in node by node.
The controls are polled, not bound to input actions: the ride tick asks the player controller directly whether W or S or A or D is down this frame. That sidesteps a couple of nasty input-asset traps in this project, but it has a consequence that turns out to be the whole honesty problem of this entry, so remember it — synthetic input injected by an automated test cannot satisfy a raw "is this key down" query. A scripted play session can prove a great deal about this bike. It physically cannot press W.
The symptom
The developer's verdict after the first real playtest with hands on a keyboard: "ultra difficult on keyboard … far too harsh". In search-box terms: keyboard steering feels like an on/off switch. The bike snaps to full lock instantly and twitches. Mouse steering pins to full lock on the smallest flick.
Why it happened
My first instinct was that the steering rate was too high, and that instinct was wrong. It is worth saying why it was wrong, because that is the useful part.
The input function computed the steering command like this, and assigned it directly:
SteerInput = clamp( (D ? 1 : 0) - (A ? 1 : 0) + mouseDeltaX * MouseSteerGain, -1, 1 )
A key is a boolean. So that expression is a step function: zero on one frame, ±1 on the next, and full lock arrives in a single frame no matter what the rate is. Halving the rate does not fix that — it just gives you a slower switch. The harshness was never in the magnitude, it was in the edge.
Two things made it worse.
The lean was already smoothed and the heading was not. The bike's visual body roll eased towards its target with an interpolation at speed 5. The actual yaw did not. So the frame the key went down, the machine changed direction instantly while the model was still easing over. That mismatch is exactly what a player reads as "harsh" — the picture and the motion disagree.
The steering authority curve did nothing over most of the speed range. Authority scaled as clamp(|speed| / 200, 0, 1). Top speed is about 715 cm/s — roughly 16 mph. So the scaler saturates at 200 and from walking pace upwards you had identical, full steering authority at 4 mph and at 16 mph. The divisor was picked when the bike was slower and never revisited against the real range.
And the mouse term used a raw per-frame delta with a gain of 0.85, unnormalised by frame time, so an ordinary flick pinned that term past ±1 before the clamp had even looked at the keys.
The fix
Smooth the command, not the response. The raw expression became a target, and the live value now eases towards it:
SteerInput = FInterpTo( SteerInput, raw, deltaSeconds, SteerSmooth )
with SteerSmooth at 5.0, so full lock takes roughly a fifth of a second instead of one frame. Alongside that: the steer rate came down from 95 to 55 °/s, mouse gain from 0.85 to 0.30, and maximum lean from 15° to 7° so the bike stays more upright. The authority curve picked up a high-speed taper:
authority = clamp(|v|/200, 0, 1) × lerp(1.0, SteerHighSpeedFactor, clamp(|v|/topSpeed, 0, 1))
with the factor at 0.45 — about 46 °/s of turn at low speed and about 25 °/s flat out, instead of a flat 95 everywhere.
The guard is the two dials. SteerSmooth and SteerHighSpeedFactor are both exposed as instance-editable numbers on the placed bike. The next tuning round is somebody dragging a slider, not me doing graph surgery again. I also checked that the placed bike carried no instance overrides of any of those values, because an override on a placed actor silently beats whatever you set on the class — this project has been bitten by that more than once.
The headlamp, which was late for a completely different reason
Symptom: the headlamp comes on hours after it gets dark.
The bike carried its own private copies of a sunrise hour and a sunset hour, set to 04:48 and 21:18. Those are honest values for real British midsummer, and at one point they were copied deliberately from the day/night manager so the two would agree.
They were still wrong, because the sun in this game does not obey them. Its elevation is driven by a sine of (hour − 6) × −15 degrees, which is zero at 06:00 and zero again at 18:00 whatever any sunrise field says. Dark, in this world, starts at six in the evening. The lamp was waiting until quarter past nine. Setting the bike's hours to 18.0 and 6.0 fixed it. The lamp logic itself was always correct — a half-second looping timer re-evaluating "past sunset or before sunrise", deliberately kept off the frame tick.
The real lesson is the duplication: two systems each holding a private copy of a shared world constant. The proper fix is for the bike to read the day/night manager's values rather than keeping its own. That has not been done, and until it is, this bug can come back.
Two smaller things fell out of the same work. First, the Blueprint had been compiling with a warning that a pin could not be found for a variable that plainly existed. It was a zero-pin duplicate "get" node parked exactly on top of the real one, invisible in the editor. Deleting it gave a clean compile. A compile warning naming a pin that "isn't found" is usually a dead duplicate node, not a broken reference. Second, on an earlier pass the beam was weak and washy despite a large intensity value, because the spotlight's inner cone angle was 0 — the falloff started on the axis itself. Widening the inner cone did more than any intensity change.
What this day did not prove
All of it is reasoned. None of it has been ridden.
An automated play session verified plenty: mounting and possession, the rider being hidden and having collision switched off, the exit control arming after its delay, the kickstand folding, the little handlebar display acquiring and driving its widget, wetness being read live from the weather system, and zero runtime errors. That is real. But because the controls poll raw key state, that same session could not press a single movement key. The ride feel — which is the entire subject of this entry — is untested by anything except my arithmetic.
Two other ebike items are in the same condition. A dismount bug that teleported the rider back to where the bike had been parked has now been fixed twice; the second attempt stops the rider's movement component, teleports, and then re-asserts the position on a short timer so a first-frame revert gets undone about seven frames later. I have deliberately left a diagnostic line in that path, because it is the one thing that will distinguish "the teleport never landed" from "it landed and something reverted it" — and two rounds of guessing could not settle that. Neither the dismount fix nor the ride-feel numbers have been in front of a human yet. Still open beyond that: the drivetrain alone is 200k triangles with a single level of detail, and there is no pedalling animation or wheel spin, because the donor mesh is one welded lump.
What to take from it
- Smooth the input, not the output. A key is 0 or 1. Assign that straight to a control
axis and you have built a step function; lowering the rate only gives you a slower switch.
- If one channel is smoothed and another isn't, that mismatch is the harshness. The lean
eased, the heading snapped, and the disagreement is what a player actually feels.
- Check where your normalising divisor saturates against the real range. Dividing speed by
200 when the top speed is 715 means the curve is flat across most of the values it will ever see.
- Two systems holding private copies of one world constant will drift, and the drift
presents as a timing bug rather than a visibly wrong number.
- A compile warning about a pin that cannot be found is usually a dead duplicate node. Look
for the invisible one sitting on top of the real one.
- Reasoned numbers are not tested numbers, and the write-up should say which it has. Where
the test harness structurally cannot exercise the thing — as here, where injected input cannot satisfy a raw key query — say so out loud, and expose the two values that matter as editable dials, so the human's feedback costs a slider drag rather than another round of edits.