21 September 2026 · Engineering
A velocity write in _process cost me three weeks
The end of Chapter 2 is an escape. A dragon called Vharos comes up the shaft behind you, and partway through the climb you are given a boost to outrun him. The boost fired. The flames were enormous. The rocket went exactly as fast as it had been going before.
It looked like a tuning problem, so I tuned it
That is the part worth telling, because it is the part that cost the time. A boost that fires and does not feel like enough is the most ordinary problem in game development. You made the number too small. So you make the number bigger.
I took the multiplier to 4x. No change. I took it higher. No change. At that point I should have stopped and asked why an input had stopped mapping to an output at all, because that is not what an under-tuned value does. An under-tuned value gets a bit better when you raise it. This one was inert.
Instead I assumed I had wired the boost into the wrong state, and went looking through the state machine. Three weeks, on and off, of looking in the wrong place.
The number that actually gave it away
Whatever I did, the climb settled at about 395 px/s.
Not roughly. Not drifting around. The same figure, every run. And I recognised it, which is what finally turned it around: 395 px/s was the equilibrium speed of the chase, the pace the pursuit logic holds you at so the dragon stays a threat without ever quite catching you.
The boost was not being applied weakly. It was not being applied. Something was putting the craft back at chase speed after every single physics step, and it was doing it so consistently that I had been reading a symptom of total override as a symptom of insufficient force.
What was actually happening
The level's orchestration ran from _process. Inside it, the boost state did a
read, modify, write on the craft's velocity every rendered frame, a small lateral bleed that
was meant to straighten the climb while preserving the vertical component.
# the level ran this from _process, once per RENDERED frame
func _process_level_30(delta: float) -> void:
...
# "just straighten the drift, keep y"
craft.linear_velocity = Vector2(
lerp(craft.linear_velocity.x, 0.0, delta * 3.0),
craft.linear_velocity.y,
)
That line reads harmless. It only touches x, and it hands y straight back. But it is a write to the whole vector, and it is happening from the render loop, in between physics ticks.
A RigidBody2D accumulates the result of forces during its physics step. If you assign to
linear_velocity from outside that step, you are not adding to what the body
worked out. You are replacing it with a value you read before it worked it out. The
boost applied its force on the tick, the body integrated it, and then a render frame arrived
and pasted the pre-boost velocity back over the top.
I confirmed it by deleting the line and changing nothing else. Terminal velocity through the boost went from 449 px/s to roughly 2,500 px/s.
Why it hid for so long: it was wearing three other bugs
This is the part I think is genuinely worth passing on. I was not tracking one bug. I had three open, and I had them filed separately.
- The boost felt weak. The one I was actually chasing.
- The story beats fired in the wrong place. The escape has a run out into open sky at the top, with dialogue over it. All of it was happening inside the shaft, in the dark, with rock on both sides. The boost had a 15 second clock, and at the speed the craft was really travelling that clock ran out about 13,000 px below the top. The sequence was not mistimed. It was correctly timed for a rocket that was moving six times too slowly.
- The finale killed you. The landing pad spawns 250 m above the craft at the end of the climb. For a rocket climbing fast, that is ahead of you and you fly up to meet it. For a rocket crawling at chase speed it is an anvil dropped directly overhead, and you hit the underside of it, and you die at the end of the chapter through no fault of your own.
Three tickets, three theories, one line of code. Every hour I spent treating them as unrelated was an hour spent guaranteeing I would not find it, because none of them makes sense on its own and all three are obvious the moment you know the craft is stuck at one speed.
The rule I actually took away
Anything that writes a physics body's velocity or position runs on the physics tick. Not sometimes. Not when it is convenient.
# wrong: runs on the render frame, clobbers force integration
func _process(delta: float) -> void:
craft.linear_velocity = ...
# right: runs on the tick, in the same step the forces are integrated
func _physics_process(delta: float) -> void:
craft.linear_velocity = ...
The narrow version of this rule is well known and I would have said I already followed it. The
version that would have saved me is broader: it is not about the write, it is about the
file. The level was never written as "physics code". It was written as staging: play a
line, move a camera, nudge the drift. It grew a velocity write later, and the function it lived
in never got revisited, because nothing about _process complains when you do this.
No error, no warning, no log line. It just silently wins.
Not every level moved. The ones that only trigger dialogue and camera work stayed exactly where they were, because they never touch body state. Only the ones that write to the craft had to move, and knowing which is which is the whole of it.
How to spot it in ten minutes instead of three weeks
Log velocity and actual position change on the same tick, and compare them.
var moved := craft.global_position - last_pos print(craft.linear_velocity * delta, " vs ", moved)
If something outside the tick is overwriting velocity, these two disagree: the body moves further than its reported velocity says it should, because the velocity you are printing is the one that got pasted on afterwards, not the one the body actually integrated. Two numbers that should match and do not, and it points straight at the cause.
The general tell, before you even reach for a log: a craft that ignores an input at a suspiciously constant speed is not under-powered, it is being overwritten. Weak forces give you weak, variable results. A number that will not move is something else holding it there.
The test that stops it coming back
The fix itself was a few lines. The thing I am more pleased with is that this finale is now flown, in full, with no one watching.
There is a headless rig that boots the real game scene at the start of the escape and flies the whole sequence with an autopilot: the burn, the dragon waking, the chase, the boost, the run out, the pad, the landing. It asserts that the craft lands and does not crash, and it prints the speed it hit through the boost. If a velocity write ever creeps back in, that number collapses to about 400 and the test fails on the spot.
This bug was invisible for three weeks because nothing was watching the one number that mattered. Now something is, on every run.
If you would rather just fly it than read about it, the first five levels run in your browser with no install: play the demo. The full game is free on Android, 56 hand-built levels across four worlds.