Physics simulation is just updating position from velocity, and velocity from acceleration, every step. Learn numerical integration, why semi-implicit Euler is the game default, and the fixed timestep.
A physics simulation advances in small time steps. Each step, acceleration (from forces like gravity) changes velocity, and velocity changes position. This is numerical integration — approximating continuous motion with discrete updates. Every physics engine, at its core, is this loop running many times per second.
# the heart of every physics engine: integrate motion each step
pos = [0.0, 0.0]
vel = [5.0, 0.0]
GRAVITY = [0.0, -9.8]
def step(pos, vel, dt):
# acceleration -> velocity, velocity -> position
vel[0] += GRAVITY[0] * dt
vel[1] += GRAVITY[1] * dt
pos[0] += vel[0] * dt
pos[1] += vel[1] * dt
for _ in range(3):
step(pos, vel, dt=1/60)
print([round(p, 3) for p in pos])
# forces -> acceleration -> velocity -> position, every step. That's it.The order of the updates matters. Explicit Euler updates position from the old velocity; semi-implicit (symplectic) Euler updates velocity first, then uses the new velocity for position. The semi-implicit version is more stable and conserves energy far better, which is why it is the standard integrator in games — a one-line ordering choice with a big impact.
# EXPLICIT Euler: position uses OLD velocity (drifts, gains energy -> unstable)
def explicit(pos, vel, acc, dt):
pos += vel * dt # <-- old velocity
vel += acc * dt
# SEMI-IMPLICIT Euler: update velocity FIRST, then use the NEW velocity
def semi_implicit(pos, vel, acc, dt):
vel += acc * dt # velocity first
pos += vel * dt # <-- new velocity -> stable, energy-conserving
# same amount of code, but semi-implicit is the GAME default: it doesn't
# blow up and keeps orbits/bounces stable. Just order velocity before position.
print("update velocity, THEN position")Forces cause acceleration through F = ma: acceleration is force divided by mass. You accumulate all forces on a body each step (gravity, thrust, drag), divide by mass to get acceleration, then integrate. Modeling gameplay as forces — a jump impulse, wind, a spring — gives natural, composable motion rather than hand-scripted movement.
class Body:
def __init__(self, mass):
self.mass = mass
self.pos = [0.0, 0.0]
self.vel = [0.0, 0.0]
self.force = [0.0, 0.0] # accumulated each step
def apply_force(self, f):
self.force[0] += f[0]; self.force[1] += f[1]
def step(self, dt):
# F = m*a -> a = F/m
ax = self.force[0] / self.mass
ay = self.force[1] / self.mass
self.vel[0] += ax * dt; self.vel[1] += ay * dt # velocity first
self.pos[0] += self.vel[0] * dt; self.pos[1] += self.vel[1] * dt
self.force = [0.0, 0.0] # clear forces for next step
b = Body(mass=2)
b.apply_force([0, -9.8 * b.mass]) # gravity as a force
b.step(1/60)If you integrate with a variable frame time, physics becomes non-deterministic and unstable — a lag spike makes objects tunnel through walls. The fix is a fixed timestep: run the physics update in constant-size steps, accumulating leftover render time, so simulation is stable and reproducible regardless of frame rate. This decoupling of physics from rendering is a cornerstone of correct game loops.
Fixed timestep required — Run physics at a constant dt (e.g. 1/60s), not the variable frame time. A variable dt makes physics unstable and non-deterministic; a lag spike can let objects pass through walls.FIXED_DT = 1 / 60 # physics always steps by this constant
accumulator = 0.0
def game_loop(frame_time):
global accumulator
accumulator += frame_time # leftover real time to simulate
# run physics in constant-size steps until we've caught up
while accumulator >= FIXED_DT:
physics_step(FIXED_DT) # deterministic, stable
accumulator -= FIXED_DT
render() # render as often as you like
# physics runs at a CONSTANT rate; rendering runs at whatever fps. This keeps
# the simulation stable + reproducible and prevents tunneling on lag spikes.