Building a lunar lander game in Python with pygame
The heart of the game resides in main.py, which orchestrates the entire process. It initializes Pygame, sets up the display window, and manages the main game_loop.
# Simplified game loop structure from main.py
def game_loop(mode: str):
pygame.init()
screen = pygame.display.set_mode((cfg.width, cfg.height))
pygame.display.set_caption("Lunar Lander")
clock = pygame.time.Clock()
font = pygame.font.Font(None, 25)
# ... Initialize sound, GameLogic, LanderVisuals ...
running = True
game_over = False
has_started = False # Track if player has initiated movement
while running:
clock.tick(cfg.fps) # Control frame rate
action = 0 # Default action: Noop
# --- Event Handling ---
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_q: running = False
# Start game logic updates once player moves (in 'play' mode)
if mode == 'play' and event.key in [pygame.K_UP, pygame.K_LEFT, pygame.K_RIGHT]:
has_started = True
# --- Action Determination (Manual Play) ---
if not game_over and mode == 'play':
keys = pygame.key.get_pressed()
if keys[pygame.K_UP]: action = 1
elif keys[pygame.K_LEFT]: action = 2
elif keys[pygame.K_RIGHT]: action = 3
# --- Game Logic Update ---
# Only update if game has started and not over
if has_started and not game_over:
state, done = logic.update(action) # logic is GameLogic instance
if done:
game_over = True
# ... Handle game over message ...
# --- Rendering ---
draw_game(screen, logic, visuals, sounds, font) # visuals is LanderVisuals instance
# --- Game Over Handling ---
if game_over:
pygame.time.delay(1500) # Pause briefly
running = False
pygame.quit()
This loop continuously checks for player input (keyboard events), determines the appropriate action (thrusting up, left, or right), updates the game’s state through the GameLogic module, and then redraws the screen.
Rendering the scene
The draw_game function is responsible for painting the current state onto the screen. It uses information provided by GameLogic (get_render_info) and visual assets managed by LanderVisuals.
- Clear Screen: Fills the background.
- Draw Terrain & Pads: Simple rectangles represent the ground and the crucial landing pads (start and target).
- Draw Lander: The lander’s image, pre-loaded and scaled by
LanderVisuals, is blitted (drawn) at its current(x, y)coordinates. - Draw Flames: Based on the
last_actionand remainingfuel, the appropriate flame animation (vertical, left, or right) is drawn relative to the lander’s position. TheLanderVisualsclass handles loading and scaling these flame images. - Play Sound: If thrusting and sounds are enabled, the engine sound effect is played.
- Draw HUD: Key information like remaining
Fuel, currentx/yposition, andvx/vyvelocity are rendered as text onto the screen.
# Snippet from draw_game in main.py
def draw_game(screen: pygame.Surface, logic: GameLogic, visuals: LanderVisuals,
sounds: SimpleNamespace, font: pygame.font.Font):
render_info = logic.get_render_info()
lander_x, lander_y = render_info["x"], render_info["y"]
fuel, vx, vy = render_info["fuel"], render_info["vx"], render_info["vy"]
last_action = render_info["last_action"]
screen.fill(c.k) # Black background
# Draw terrain (white rect) and pads (red/green rects)
pygame.draw.rect(screen, c.w, (0, cfg.height - gcfg.terrain_y, cfg.width, gcfg.terrain_y))
pygame.draw.rect(screen, c.r, (gcfg.spad_x1, gcfg.pad_y1, gcfg.spad_width, gcfg.pad_height)) # Start pad
pygame.draw.rect(screen, c.g, (gcfg.lpad_x1, gcfg.pad_y1, gcfg.lpad_width, gcfg.pad_height)) # Landing pad
# Draw Lander image
scaled_images = visuals.get_scaled_images()
screen.blit(scaled_images["lander"], (lander_x, lander_y))
# Draw Flames conditionally
play_sound = False
if last_action == 1 and fuel > 0: # Up
screen.blit(scaled_images["vflames"], (lander_x + ..., lander_y + ...))
play_sound = True
elif last_action == 2 and fuel > 0: # Left
screen.blit(scaled_images["rflames"], (lander_x + ..., lander_y + ...)) # Right flames for left thrust
play_sound = True
# ... elif for right thrust ...
# Play sound
if cfg.with_sounds and play_sound and not pygame.mixer.get_busy():
sounds.engine_s.play()
# Draw HUD text
fuel_text = font.render(f'Fuel: {int(fuel)}', True, c.w)
screen.blit(fuel_text, (10, 10))
# ... render and blit position (p_text) and velocity (v_text) ...
pygame.display.flip() # Update the full display surface
The LanderVisuals class (mod_lander.py) simply loads and scales the necessary PNG images (lander_1.png, vertical_flames.png, etc.) during initialization, making them readily available for the draw_game function.
Configuration
Many aspects of the game are controlled via mod_config.py. This includes:
- Screen dimensions (
cfg.width,cfg.height). - Lander appearance and flame offsets (
lander_cfg). - Initial game state, pad locations (which can be randomized!), and landing difficulty (max speeds
gcfg.max_vx,gcfg.max_vy). - Physics parameters like gravity (
planet_cfg.g).
This separation makes it easy to tweak the game’s look, feel, and difficulty without digging deep into the core logic.
Physics Simulation
While this post focuses on the UI, it’s worth noting that mod_game_logic.py handles the simulation’s core physics. The GameLogic class tracks the lander’s position (x, y), velocity (vx, vy), and fuel. Its update method applies gravity, friction, and thrust based on player actions, calculates the new state, and checks for landing or crashing conditions according to the rules defined in mod_config.py.
This modular approach creates a playable Lunar Lander experience, separating concerns between game logic, visual presentation, and configuration.
For more insights into this topic, you can find the details here.
The code for this implementation is available on Github here.