The boot.dev Asteroids course gives you a ship that moves while you hold a key and stops when you let go. It works, it is simple, and it feels nothing like being in space. What I wanted was continuous motion: you thrust, and then you keep going, because nothing out there is slowing you down.

That is a small change to describe and a surprisingly interesting one to build, because the honest version of it is unplayable.

Velocity instead of position

Direct movement writes to position every frame. Momentum means the keys no longer control position at all; they control acceleration, and position becomes something that follows from velocity:

def apply_thrust(self, dt):
    thrust_direction = pygame.Vector2(0, 1).rotate(self.rotation)
    self.thrust_vector += thrust_direction * PLAYER_THRUST * dt
    if self.thrust_vector.length() > PLAYER_MAX_VELOCITY:
        self.thrust_vector.scale_to_length(PLAYER_MAX_VELOCITY)

Two things worth noting. Thrust is applied in the direction the ship currently faces, which is what makes the ship feel like it has mass: rotate ninety degrees and thrust, and you do not turn, you slide sideways while slowly curving.

A trail of ghosted ship outlines moving left to right, with the ship pointing straight up while a green velocity arrow points right

That is the whole feeling in one frame: the nose points up, the ship travels right, and the two have nothing to do with each other until you burn. And the speed cap uses scale_to_length rather than clamping the components, so hitting maximum speed never changes your heading. Clamping x and y separately would quietly rotate you toward the diagonals.

Then position just integrates velocity:

self.position += self.thrust_vector * dt

The compromise

Here is the line that is scientifically wrong on purpose:

PLAYER_DRAG = 0.99   # Friction coefficient (1.0 = no drag)

Space has no drag. Physically, 1.0 is the correct value and I knew it when I typed 0.99.

The problem is that correct is not playable. With no drag at all, every thrust is permanent. You tap forward, and now you are moving forward forever, and the only way to stop is to turn exactly 180 degrees and burn precisely the same amount. Miss slightly and you are drifting diagonally for the rest of the game. Playing it feels less like piloting a spacecraft and more like arguing with one.

That one percent bleed per frame is small enough that you still glide, still overshoot, still have to plan a stop before you need one. But it means a drifting ship eventually settles, so a mistake costs you a few seconds instead of the whole round. The realism I actually wanted was the feeling of momentum, and past a certain point more physical accuracy was buying less of it.

There is a genuine technical wrinkle in that line too. Everything else in the update loop is scaled by dt, which makes it frame-rate independent, but the drag is a flat per-frame multiplier. At a capped 60 fps it behaves consistently, so it never bit me. On an uncapped loop, faster frames would mean more multiplications per second and therefore more drag, and the ship would handle differently on different machines. The dt-correct form is PLAYER_DRAG ** dt. Worth knowing which of your constants are secretly tied to frame rate.

The part I am most pleased with

If the ship has momentum, so does everything that leaves it:

def shoot(self):
    base_velocity = pygame.Vector2(0, 1).rotate(self.rotation) * PLAYER_SHOOT_SPEED
    shot.velocity = base_velocity + self.thrust_vector

A bullet fired from a moving ship carries the ship’s velocity plus the muzzle velocity. Fire while drifting fast and your shots visibly rake off at an angle from where you are pointing. Fire while flying backwards and they crawl away from you.

Nobody asked for that, and it is the detail that sells the whole thing. Once the ship has momentum, anything that does not inherit it looks wrong, and the fix is one vector addition.

The same logic pushed the screen wrapping down into the shared CircleShape base class rather than the player:

def wrap_position(self):
    if self.position.x < -self.radius:
        self.position.x = SCREEN_WIDTH + self.radius
    elif self.position.x > SCREEN_WIDTH + self.radius:
        self.position.x = -self.radius
    # ... same for y

Ship, asteroids and bullets all wrap, so nothing accumulates at an invisible wall and the field reads as continuous rather than as a box. Wrapping at ±radius instead of at zero means objects slide fully off one edge before reappearing, so nothing pops.

The game running: the ship drifting with its thrust plume lit, irregular asteroids scattered across the field, and shots in flight

What it taught me

Physical accuracy is a means, not a goal. The ship needed to feel like it had mass, and getting there took Newtonian velocity, a cap that preserves heading, bullets that inherit momentum, and one deliberately unphysical constant to make the whole thing controllable. The unrealistic line is the one that makes the realistic ones enjoyable.

I also left PLAYER_SPEED = 200 sitting in the constants file, unused since the day position stopped being set directly. Little fossils like that are worth deleting; they tell the next reader that a mechanism exists which no longer does.

Code: B3ardBr0/asteroids. The course covers the game’s core; the momentum, explosions, menu, lives and scoring are extensions I added afterwards.