Space-Game

Physics & Coordinate System

Core physics simulation and critical coordinate system understanding.

Coordinate System (Critical!)

Game Space: GAME_WIDTH x GAME_HEIGHT (2400x1800, logical, never changes) is the camera’s reference viewport size — not a hard boundary. The world itself is open and unbounded: ships can fly arbitrarily far in any direction with no edge, no wrap.

Screen Space: Variable (scales with window)

Conversion Functions

def get_scale():
    # Aspect ratio maintained, letterboxed if needed
    return min(screen_width / GAME_WIDTH, screen_height / GAME_HEIGHT)

def get_offset():
    # Center game-space on screen
    scale = get_scale()
    offset_x = (screen_width - GAME_WIDTH * scale) / 2
    offset_y = (screen_height - GAME_HEIGHT * scale) / 2
    return (offset_x, offset_y)

def to_screen(x, y):
    # Convert game-space (x,y) to screen-space
    scale = get_scale()
    offset_x, offset_y = get_offset()
    return (int(round(x * scale + offset_x)), int(round(y * scale + offset_y)))

Common Mistakes:

Correct Pattern:

# Update in game-space
self.x += self.velocity_x
self.y += self.velocity_y

# Draw in screen-space
pygame.draw.circle(surface, color, to_screen(self.x, self.y), radius)

Movement & Velocity

Thrust System

# Thrust increases when key pressed, decays when released
if keys[UP_KEY]:
    self.thrust = min(self.thrust + 0.02, max_thrust)  # Accelerate
else:
    self.thrust = max(self.thrust - 0.02, 0)  # Coast down

# Thrust applies force in direction ship is facing
rad = math.radians(self.angle)
self.velocity_x += math.sin(rad) * self.thrust
self.velocity_y -= math.cos(rad) * self.thrust

Key behaviors:

Velocity Capping

# After applying thrust, clamp to max speed
speed = math.sqrt(self.velocity_x ** 2 + self.velocity_y ** 2)
if speed > max_velocity:
    scale = max_velocity / speed
    self.velocity_x *= scale
    self.velocity_y *= scale

Why clamp? Prevents unlimited acceleration when thrust is applied every frame.

Drag (Friction)

self.velocity_x *= drag  # 0.98 = 2% loss per frame
self.velocity_y *= drag

Result: Ship gradually slows down when thrust is off, creating drifting feel.

Constants:

“per frame” = “per simulation step”. The main loop runs a fixed-timestep accumulator (SIM_STEP = 1/60 s, see UI_FLOW.md): step_world() runs once per rendered frame on a machine holding ~60 FPS, and multi-steps only to catch up on a sustained slowdown (it deliberately holds at one step through normal jitter — see “Frame Timing & Smooth Motion” below). Every constant here is calibrated to that 1/60 s step and must not be converted to per-second — SIM_STEP is fixed precisely so they don’t need to be.

Rotation & Facing Direction

2D Rotation Matrix

When drawing a rotated polygon, use proper 2D rotation:

rad = math.radians(angle)
cos_a = math.cos(rad)
sin_a = math.sin(rad)

for lx, ly in local_points:
    rotated_x = lx * cos_a - ly * sin_a
    rotated_y = lx * sin_a + ly * cos_a
    world_x = center_x + rotated_x
    world_y = center_y + rotated_y

Common Mistake: Using separate sin/cos for each point causes polygon deformation.

Thrust Flame Direction

Flame shoots opposite to ship facing:

# Find back center of ship polygon
mid_back_x = (left_back_x + right_back_x) / 2
mid_back_y = (left_back_y + right_back_y) / 2

# Rotate back point to world space
back_x = self.x + (mid_back_x * cos_a - mid_back_y * sin_a)
back_y = self.y + (mid_back_x * sin_a + mid_back_y * cos_a)

# Extend flame in opposite direction
flame_length = self.thrust * 30
flame_x = back_x - sin_a * flame_length
flame_y = back_y + cos_a * flame_length

Collision & Boundary Detection

No World Boundary — Chunk-Streamed Background Instead

There used to be a wrap_position() that teleported ships back to the opposite edge at GAME_WIDTH/GAME_HEIGHT (torus topology). That’s gone — ships now fly freely with no boundary at all. To still give the player something to see arbitrarily far from the start, StarField and AsteroidField (starfield.py/asteroid_field.py) generate their content procedurally instead of pre-placing it. Each frame, the visible chunk range is computed from the camera position (utils.camera_offset_x/y) plus a margin; any chunk not yet generated is generated on the spot, and any chunk far enough behind the camera is dropped. This keeps memory bounded while letting the player explore indefinitely in any direction.

The two fields differ deliberately in how a chunk gets (re)generated:

# StarField: deterministic per-chunk hash - same (seed, cx, cy) always
# regenerates the same stars, so backtracking looks consistent.
def _chunk_seed(self, cx, cy):
    return (self.seed * 73856093) ^ (cx * 19349663) ^ (cy * 83492791)

# AsteroidField: one random.Random advances continuously across every
# chunk it ever generates (never reseeded by position), so a chunk that
# gets unloaded and later revisited rolls fresh asteroids instead of
# replaying the same ones - asteroids are meant to feel different each
# time you come back, stars aren't.

Why chunk-and-forget instead of one big pre-generated field? A fixed field either has to be huge (wasteful, and still finite) or small (visibly runs out). Generating per-chunk on approach means there’s no upper bound on how far the player can go.

Range Checking

def get_distance(self, x, y):
    dx = self.x - x
    dy = self.y - y
    return math.sqrt(dx**2 + dy**2)

# Use for interaction checks
if player.get_distance(station.x, station.y) < 100:
    show_land_prompt()

Collision Areas (Station Interior)

A culture-tagged interior’s walkable area is the union of its room polygons (LocationScreen.rooms, from the interior’s "rooms" config; normalize_room folds "rect" / "polygon" / "circle" shapes to one polygon form). Overlapping polygons read as one connected space, so a wide corridor is just another polygon overlapping the rooms it joins. Movement is allowed anywhere in that union, with a cheap bounding-box reject before the per-vertex ray cast:

def can_move_to(self, x, y):
    if any(fx <= x <= fx+fw and fy <= y <= fy+fh for fx,fy,fw,fh in self.building_footprints):
        return False
    if self.rooms:
        return any(bx0 <= x <= bx1 and by0 <= y <= by1 and point_in_polygon(x, y, room["polygon"])
                   for room in self.rooms for (bx0, by0, bx1, by1) in [room["bounds"]])
    return 0 < x < self.world_width and 0 < y < self.world_height

point_in_polygon is an even-odd ray cast (concave-safe) that counts a point on any edge as inside. An interior with no "rooms" falls back to the full world rect. AI walking (DockRoutine) uses plan_path() - a grid A* over this same can_move_to (see ARCHITECTURE.md’s “Walkability-oracle navigation”).

Actual on-foot motion - player, wanderers, and dock pilots alike - goes through Person.step_toward(tx, ty, speed, can_move_to): one normalized step (diagonals aren’t faster), capped at the distance to the target, wall-sliding (full step → x-only → y-only) off whatever can_move_to rejects. speed is LocationScreen.speed (story.json walking_speed, world units per 1/60 s step); WanderRoutine uses its own slower WANDER_SPEED. See DESIGN_PATTERNS.md’s “One Movement Primitive on the Base Entity”.

Frame Timing & Smooth Motion — two deliberate tradeoffs

Motion smoothness on this engine runs into two hard constraints. Both are currently resolved by a calibrated compromise, not a real fix. This section records why, and what the real fixes would cost, so a future change is a decision and not a surprise.

1. The sim drops a sliver of time to stay visually smooth

advance_accumulator (game/utils.py) does not do a textbook floor(accumulator / step). It runs exactly one step for any frame worth ~0.5–2.5 steps, and multi-step catch-up only on a sustained slowdown. The positive remainder is clamped well under a step — i.e. a persistent sub-step surplus is discarded rather than banked for a later catch-up frame.

2. Non-integer scroll speed shimmers on a whole-pixel grid

to_screen() ends in int(round(...)) — every drawn point snaps to a whole pixel. When the camera pans, a fixed world point moves at walking_speed × get_scale() screen-px/frame; at common window sizes that’s ~3.3 px/frame, which the pixel grid renders as an irregular 3-3-3-4 cadence — a faint ~15 Hz shimmer of the world (the player stays put, centred) while running left/right.

Performance Considerations

Optimization done:

Not optimized (and doesn’t need to be at this scale):

For the current entity count (<10), per-entity physics is fine.

Common Bugs & Fixes

Bug: Graphics deform on resize

Bug: Ships accelerate indefinitely

Bug: Ship polygon looks squished/stretched

Bug: Flame drawn off to the side

Bug: Star chunks look different on revisit

See patterns/rendering.md for the coordinate conversion pattern.