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.

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.

Get it on Google Play