Lesson 4: Coordinate Systems
Most game worlds are much bigger than the window you see them through. In this lesson you learn to keep "where a thing is in the world" separate from "where it appears on screen", and build a camera that scrolls across a big level with two lines of arithmetic.
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Explain pygame's screen coordinates, including why the last pixel of an 800-pixel-wide window is at x = 799.
- Convert between screen coordinates and math-style coordinates (origin in the middle, y pointing up).
- Convert world positions to screen positions with a camera offset, and screen positions (such as a mouse click) back to world positions.
- Build a camera that follows the player and never shows anything outside the world.
- Debug the classic sign mix-up that makes clicked objects slide when the camera moves.
Project: Pan, Click, Convert: pan a camera over a large world, drop markers where you click, and watch one mouse position in three coordinate systems at once.
In This Lesson
๐ Screen Space, Up Close
You already know the basics from Drawing Shapes & Surfaces: screen coordinates are measured in pixels from the window's top-left corner, with x growing right and y growing down. Two details matter once things start moving:
- Counting starts at 0. An 800-pixel-wide window has pixels 0, 1, 2, โฆ 799. There are 800 of them, but the last one is at x = 799. A point at x = 800 is just outside, which is why off-by-one mistakes are so common.
- A rectangle's right edge is "one past". For
pygame.Rect(0, 0, 800, 600),rect.rightis 800 andrect.bottomis 600: the first column and row after the rectangle. So a square of sizeSIZEfits inside the window whilexstays between 0 andWIDTH - SIZE, the clamp you used in Keyboard, Mouse & Gamepad.
import pygame
window = pygame.Rect(0, 0, 800, 600)
print(window.right, window.bottom) # 800 600: one past the last pixel
print(window.collidepoint(799, 599)) # True: the bottom-right pixel is inside
print(window.collidepoint(800, 599)) # False: x = 800 is outside
๐ Math-Style Coordinates
Screen coordinates are perfect for drawing, but sometimes you want to think the way math class does: the origin in the middle and "up" meaning positive y. Plotting a graph, placing things symmetrically around the center of a menu, or reading a formula from a physics book are all easier that way. The conversion is two subtractions:
WIDTH, HEIGHT = 800, 600
def screen_to_math(screen_x, screen_y):
"""Origin at the window's center, y pointing up."""
return screen_x - WIDTH / 2, HEIGHT / 2 - screen_y
def math_to_screen(math_x, math_y):
return math_x + WIDTH / 2, HEIGHT / 2 - math_y
x only shifts, so the center column becomes 0. y shifts and flips: HEIGHT / 2 - screen_y is positive above the center line and negative below it. Check it with the corners: the top-left pixel (0, 0) becomes (-400, 300), and the window's center (400, 300) becomes (0, 0).
Here is a complete program that thinks in math-style coordinates and converts only when it draws. It plots the curve y = xยฒ รท 200, a parabola, with its lowest point at the center of the window:
import pygame
WIDTH, HEIGHT = 800, 600
def math_to_screen(math_x, math_y):
"""Origin at the window's center, y pointing up."""
return math_x + WIDTH / 2, HEIGHT / 2 - math_y
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Plotting in Math Coordinates")
clock = pygame.time.Clock()
# Work out the points once, in math coordinates: y = x * x / 200.
points = []
for math_x in range(-280, 281, 10):
math_y = math_x * math_x / 200
points.append(math_to_screen(math_x, math_y))
running = True
while running:
clock.tick(60)
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
screen.fill((20, 24, 36))
# The axes: the horizontal line math_y = 0 and the vertical line math_x = 0.
pygame.draw.line(screen, (90, 96, 120), math_to_screen(-400, 0), math_to_screen(400, 0), 2)
pygame.draw.line(screen, (90, 96, 120), math_to_screen(0, -300), math_to_screen(0, 300), 2)
pygame.draw.lines(screen, (250, 204, 21), False, points, 3)
pygame.draw.circle(screen, (255, 107, 107), math_to_screen(200, 200), 6) # a point ON the curve
pygame.display.flip()
pygame.quit()
The curve opens upward on screen, as it would in a math book, even though pygame's y grows downward: the conversion does the flipping. pygame.draw.lines (plural) connects a whole list of points; False means "don't join the last point back to the first".
๐งญ Pick one convention and stick to it
This course uses screen coordinates for everything in a game and converts to math-style coordinates only when a calculation reads better that way. Whichever you use, name your functions after both ends (screen_to_math, math_to_screen) so every conversion says which way it goes.
๐ World Space and the Camera
Picture a huge poster on a wall and a phone camera pointed at part of it. The poster is the world: every tree, coin and enemy has a fixed world position on it, measured from the poster's own top-left corner. The phone screen is the window. Where a tree appears on your phone depends on where the camera is pointing. Move the camera right, and everything on the phone slides left, while the poster itself never changes.
In code, the camera is just a position: the world point that sits at the window's top-left corner. Converting is one subtraction or one addition:
def world_to_screen(world_x, world_y, cam_x, cam_y):
"""Where a world point appears in the window: subtract the camera."""
return world_x - cam_x, world_y - cam_y
def screen_to_world(screen_x, screen_y, cam_x, cam_y):
"""Which world point sits under a screen point: add the camera."""
return screen_x + cam_x, screen_y + cam_y
Try it with numbers. A coin at world (1000, 250) with the camera at (300, 0) appears at screen (700, 250). A click at screen (700, 250) with the same camera lands on world (1000, 250): the two functions undo each other.
Explore it below. Point at the canvas (or tap it) to read one spot in all three systems, press the arrows to move the camera, and click to drop markers. Markers are stored in world coordinates, so they stay on their spot in the world while the view pans. The mini-map in the corner shows the whole world and the camera's view of it.
๐ฎ Predict, then check
Put a marker on the well at world (520, 260). Now press โ twice, so the camera moves to x = 200. Where will the well be on screen? Work it out with world_to_screen, then point at it to check. (Answer: screen (320, 260).)
โ Growth Mindset: Plus or Minus? Everyone Checks
Whether to add or subtract the camera trips up beginners and veterans alike, and there is no shame in checking every time. Don't try to memorize it; test it. Put a marker exactly under the mouse and pan: if it slides along with the view, one sign is backwards. Plug in one easy set of numbers, like a camera at (100, 0), and see if the answer makes sense. The habit of checking with a tiny example will save you in every math-heavy lesson ahead.
๐ฅ A Camera That Follows the Player
A follow camera is one idea: put the player in the middle of the window. If the window is 800 wide, the camera's left edge should be 400 pixels left of the player's center. Then clamp the camera so it never shows past the edges of the world:
import random
import pygame
WIDTH, HEIGHT = 800, 600
WORLD_W, WORLD_H = 2400, 1800
SIZE = 40
SPEED = 350 # pixels per second
def world_to_screen(world_x, world_y, cam_x, cam_y):
return world_x - cam_x, world_y - cam_y
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Follow Camera")
clock = pygame.time.Clock()
font = pygame.font.Font(None, 28)
rng = random.Random(7) # same "random" trees every run
trees = [(rng.randint(0, WORLD_W), rng.randint(0, WORLD_H)) for _ in range(120)]
player_x, player_y = WORLD_W / 2, WORLD_H / 2 # WORLD position of the player's top-left
running = True
while running:
dt = clock.tick(60) / 1000
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
# Move the player in WORLD space (held keys, as in Keyboard, Mouse & Gamepad).
keys = pygame.key.get_pressed()
dx = (keys[pygame.K_RIGHT] or keys[pygame.K_d]) - (keys[pygame.K_LEFT] or keys[pygame.K_a])
dy = (keys[pygame.K_DOWN] or keys[pygame.K_s]) - (keys[pygame.K_UP] or keys[pygame.K_w])
if dx != 0 and dy != 0:
dx *= 0.7071
dy *= 0.7071
player_x = max(0, min(WORLD_W - SIZE, player_x + dx * SPEED * dt))
player_y = max(0, min(WORLD_H - SIZE, player_y + dy * SPEED * dt))
# Camera: center the player, then keep the view inside the world.
cam_x = player_x + SIZE / 2 - WIDTH / 2
cam_y = player_y + SIZE / 2 - HEIGHT / 2
cam_x = max(0, min(WORLD_W - WIDTH, cam_x))
cam_y = max(0, min(WORLD_H - HEIGHT, cam_y))
screen.fill((34, 90, 50))
left, top = world_to_screen(0, 0, cam_x, cam_y)
pygame.draw.rect(screen, (250, 204, 21), (left, top, WORLD_W, WORLD_H), 6) # world border
for tree_x, tree_y in trees:
pygame.draw.circle(screen, (20, 60, 30), world_to_screen(tree_x, tree_y, cam_x, cam_y), 18)
px, py = world_to_screen(player_x, player_y, cam_x, cam_y)
pygame.draw.rect(screen, (255, 107, 107), (px, py, SIZE, SIZE))
info = f"player world ({player_x:.0f}, {player_y:.0f}) screen ({px:.0f}, {py:.0f})"
screen.blit(font.render(info, True, (240, 240, 240), (20, 20, 28)), (10, 10))
pygame.display.flip()
pygame.quit()
Walk toward a corner and watch the info line. In the middle of the world the player's screen position stays at about (380, 280) while its world position changes: the world scrolls, not the player. Near an edge the camera stops (the clamp), and now the player walks toward the edge of the window instead. Every object in the world is drawn through the same world_to_screen, which is what keeps everything lined up.
dx = (right) - (left)is a compact form of the fourifs from the last lesson: True counts as 1 and False as 0, so it gives -1, 0 or +1.rng = random.Random(7)makes a random-number generator of its own, started from the seed 7. It has the same methods as therandommodule (rng.randint(...),rng.choice(...), andrng.uniform(a, b)for a random float betweenaandb), and likerandom.seed(7)it gives the same "random" trees every run.- The world border is drawn every frame, even when most of it is off screen. pygame draws only the part that falls inside the window and ignores the rest, so you don't have to check which corners are visible first.
- Positions are floats. The camera and player move a fraction of a pixel per frame; pygame-ce's drawing functions accept float positions, so nothing gets stuck.
๐ฑ๏ธ Clicking in the World
Mouse positions are always screen coordinates: event.pos and pygame.mouse.get_pos() know nothing about your camera. Before you use a click to place a building, pick up a coin or choose a destination, convert it to the world with screen_to_world:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
world_x, world_y = screen_to_world(event.pos[0], event.pos[1], cam_x, cam_y)
flags.append((world_x, world_y)) # store WORLD positions; draw with world_to_screen
Store things in world coordinates and convert to screen coordinates only at the moment you draw. If you store screen positions instead, every object you placed slides along with the camera, the bug in the table below.
| Symptom | Likely cause | Fix |
|---|---|---|
| Placed objects move with the camera | Stored the click's screen position | Convert with screen_to_world before storing |
| Everything scrolls the wrong way | Added the camera when drawing | World to screen subtracts the camera |
| Clicks land far from the cursor after panning | Subtracted the camera from the click | Screen to world adds the camera |
| Black strip at the world's edge | Camera not clamped | Clamp to 0 โฆ WORLD_W - WIDTH |
| Something is one pixel off at the right edge | Used WIDTH where WIDTH - 1 or WIDTH - SIZE was needed | Remember counting starts at 0 |
๐ก Why this matters
Scrolling levels, strategy maps, mini-maps and "click to move" all rest on this one idea: things live in the world, and the screen is a view of it. Keep the two apart and a camera costs you two lines of code. Mix them up and every feature you add fights the camera.
๐๏ธ Practice Exercise: Pan, Click, Convert
Objective: pan a camera over a world bigger than the window, drop markers that stay put in the world, and show the mouse position in screen, math-style and world coordinates.
Time: about 35 minutes. Starter file: pan_click_convert_starter.py (your instructor has it). It already draws a world grid, a yellow world border, five markers and a HUD, but the camera never moves and the conversion functions are unfinished. Its numbered comments match the steps below.
- Run the starter. You see part of the world; the HUD shows four lines of coordinates. (โ 2 min)
- After the event loop, read
pygame.key.get_pressed(), setdxanddyto -1, 0 or +1 from the arrows or WASD, and adddx * CAMERA_SPEED * dttocam_x(and the same for y), passing the result throughclamp_camera(). Run it: the HUD's Camera line changes, but the world doesn't move yet. (โ 7 min) - Finish
world_to_screen(): subtract the camera. Now the grid, border and markers scroll. (โ 4 min) - Finish
screen_to_world(): add the camera. Then, in the event loop, on a left click convertevent.posand append the result tomarkers. Click, pan away and pan back: the green marker must still be where you clicked. (โ 8 min) - Finish
clamp_camera()so the view never goes past the world border in any direction. (โ 5 min) - Finish
screen_to_math(). Point at the center of the window: the Math line should read about (0, 0); at the top-left corner about (-400, 300). (โ 5 min) - Pan to the bottom-right corner of the world and click there. Compare the Screen and World lines: why is the world position so much bigger? (โ 4 min)
You are done when:
- the arrow keys (or WASD) pan smoothly, and the camera stops at every edge of the world;
- a new marker appears exactly under the cursor and stays on the same spot in the world as you pan;
- the Math line reads (0, 0) at the window's center, with positive y above it;
- closing the window prints lines like
Markers: 8andLast marker (world): (1730, 1210).
๐ก Hint
Test each conversion with one easy example before you run the game. With the camera at (300, 0), world_to_screen(1000, 0, 300, 0) should give (700, 0), and screen_to_world(700, 0, 300, 0) should give (1000, 0). For the clamp, the largest camera x is WORLD_W - WIDTH, because at that point the window's right edge sits exactly on the world's right edge. If markers slide when you pan, the click was stored in screen coordinates.
โ Example Solution
If your instructor hands you the lab file, you will see a few extra lines marked lab runtime near the top and and frame_budget() in the loop. They let the instructor's checker run the program automatically for a fixed number of frames; when you run it yourself they do nothing. You never need to write them.
"""Pan, Click, Convert: Intro Lesson 4 practice exercise (solution).
Hold the arrow keys (or WASD) to pan a camera over a world that is bigger
than the window. Click to drop a marker: the click is converted from screen
to world coordinates, so the marker stays put in the world while you pan.
The HUD shows the mouse in screen, math and world coordinates.
Close the window to quit.
"""
import pygame
WIDTH, HEIGHT = 800, 600 # the window (screen space)
WORLD_W, WORLD_H = 2000, 1500 # the whole world (world space)
FPS = 60
CAMERA_SPEED = 400 # pixels per second
GRID_STEP = 200 # a world grid line every 200 pixels
BG_COLOR = (32, 34, 44)
GRID_COLOR = (58, 62, 78)
BORDER_COLOR = (250, 204, 21)
MARKER_COLOR = (255, 107, 107)
NEW_MARKER_COLOR = (74, 222, 128)
TEXT_COLOR = (235, 235, 235)
LABEL_BG = (20, 20, 28)
def world_to_screen(world_x, world_y, cam_x, cam_y):
"""Where a world point appears in the window: subtract the camera."""
return world_x - cam_x, world_y - cam_y
def screen_to_world(screen_x, screen_y, cam_x, cam_y):
"""Which world point sits under a screen point: add the camera."""
return screen_x + cam_x, screen_y + cam_y
def screen_to_math(screen_x, screen_y):
"""Math-class coordinates: origin at the window's center, y pointing up."""
return screen_x - WIDTH / 2, HEIGHT / 2 - screen_y
def clamp_camera(cam_x, cam_y):
"""Keep the camera's view inside the world."""
cam_x = max(0, min(WORLD_W - WIDTH, cam_x))
cam_y = max(0, min(WORLD_H - HEIGHT, cam_y))
return cam_x, cam_y
def draw_world(screen, cam_x, cam_y, markers, first_new):
"""Draw the grid, the world's border and every marker, converted to the screen."""
for gx in range(0, WORLD_W + 1, GRID_STEP):
sx, _ = world_to_screen(gx, 0, cam_x, cam_y)
pygame.draw.line(screen, GRID_COLOR, (sx, 0), (sx, HEIGHT))
for gy in range(0, WORLD_H + 1, GRID_STEP):
_, sy = world_to_screen(0, gy, cam_x, cam_y)
pygame.draw.line(screen, GRID_COLOR, (0, sy), (WIDTH, sy))
# The border is drawn every frame; pygame clips whatever falls outside the window.
left, top = world_to_screen(0, 0, cam_x, cam_y)
pygame.draw.rect(screen, BORDER_COLOR, (left, top, WORLD_W, WORLD_H), 4)
for i, (wx, wy) in enumerate(markers):
color = NEW_MARKER_COLOR if i >= first_new else MARKER_COLOR
pygame.draw.circle(screen, color, world_to_screen(wx, wy, cam_x, cam_y), 8)
def main():
pygame.init()
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Pan, Click, Convert")
clock = pygame.time.Clock()
font = pygame.font.Font(None, 26)
cam_x, cam_y = 0.0, 0.0
markers = [(100, 80), (550, 320), (1200, 450), (1800, 1300), (300, 1100)]
first_new = len(markers) # markers from here on were clicked
running = True
while running:
dt = clock.tick(FPS) / 1000
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
elif event.type == pygame.MOUSEBUTTONDOWN and event.button == 1:
mx, my = event.pos
markers.append(screen_to_world(mx, my, cam_x, cam_y))
keys = pygame.key.get_pressed()
dx = dy = 0
if keys[pygame.K_LEFT] or keys[pygame.K_a]:
dx -= 1
if keys[pygame.K_RIGHT] or keys[pygame.K_d]:
dx += 1
if keys[pygame.K_UP] or keys[pygame.K_w]:
dy -= 1
if keys[pygame.K_DOWN] or keys[pygame.K_s]:
dy += 1
cam_x, cam_y = clamp_camera(cam_x + dx * CAMERA_SPEED * dt,
cam_y + dy * CAMERA_SPEED * dt)
screen.fill(BG_COLOR)
draw_world(screen, cam_x, cam_y, markers, first_new)
mx, my = pygame.mouse.get_pos()
math_x, math_y = screen_to_math(mx, my)
world_x, world_y = screen_to_world(mx, my, cam_x, cam_y)
lines = [
f"Screen: ({mx}, {my})",
f"Math: ({math_x:.0f}, {math_y:.0f})",
f"World: ({world_x:.0f}, {world_y:.0f})",
f"Camera: ({cam_x:.0f}, {cam_y:.0f})",
]
for i, text in enumerate(lines):
screen.blit(font.render(text, True, TEXT_COLOR, LABEL_BG), (10, 10 + i * 24))
pygame.display.flip()
pygame.quit()
print(f"Markers: {len(markers)}")
wx, wy = markers[-1]
print(f"Last marker (world): ({wx:.0f}, {wy:.0f})")
if __name__ == "__main__":
main()
๐ Learning Journal
Take five minutes to write in your learning journal (a notebook or a plain text file works). Jot down:
- Key concepts you learned today
- Techniques that clicked (and the ones that haven't, yet)
- Questions or confusion to bring to the next session
- Ideas to try in your own game
- Progress and feelings: how did this lesson go for you?
โ๏ธ This lesson's prompts:
- Explain world coordinates and screen coordinates to a friend using a real-world comparison of your own (not the poster and the phone).
- Write down, from memory, which conversion adds the camera and which subtracts it. Then explain why with one small example.
- Think of a game with a big map. What would break if it stored clicked positions in screen coordinates?
๐ Summary
You met three ways to name the same spot. Screen coordinates count pixels from the window's top-left, y down, with the last pixel at WIDTH - 1. Math-style coordinates put the origin in the middle with y up, and are one subtraction and one flip away. World coordinates say where things really are in a level bigger than the window; a camera position turns them into screen coordinates by subtraction, and turns mouse clicks back into world coordinates by addition. With that, you built a camera that follows the player and never shows past the edge of the world.
๐ Key Takeaways
- Screen pixels run from 0 to
WIDTH - 1;rect.rightis one past the last pixel. - Math-style from screen:
(x - WIDTH / 2, HEIGHT / 2 - y). Only y flips. - World to screen subtracts the camera; screen to world adds it.
- Store positions in world coordinates; convert to screen coordinates only when drawing.
- A follow camera centers the player, then clamps to
0 โฆ WORLD_W - WIDTH(and the same for y).
๐ญ Looking Ahead
You have been juggling x and y as separate numbers, and writing everything twice. In the next lesson, Vectors with pygame.math.Vector2, you bundle each position and velocity into a single object, so movement, direction and distance each take one line.
โ Common Questions
Why is the camera position the window's top-left corner and not its center?
Because pygame draws from the top-left, so "subtract the camera" then gives screen coordinates directly. You can still aim the camera at a center point: subtract half the window size when you set it, as the follow camera does.
Do I need math-style coordinates in my game?
Often not. Most 2D games work entirely in screen and world coordinates, both with y pointing down. Math-style coordinates are handy for plotting, for symmetric layouts around a center point, and for formulas copied from a math or physics book that assume y points up.
Should I skip drawing things that are off screen?
pygame already ignores the parts of a drawing that fall outside the window, so your picture is correct either way. With thousands of objects, skipping the far-away ones can save time; measure your own game before deciding it is worth the extra code.
Can the camera zoom?
Yes. Multiply after subtracting: screen_x = (world_x - cam_x) * zoom, and divide when going back: world_x = screen_x / zoom + cam_x. Sizes (radii, widths) must be multiplied by zoom too. Try it in Going Further.
My follow camera jitters by a pixel when the player moves slowly.
Keep both the player and the camera positions as floats, and compute the player's screen position from them each frame, as the example does. Rounding one of them to a whole number and not the other makes them disagree by a pixel now and then.
๐ฏ Quick Quiz
Question 1: Your window is 800 pixels wide. What is the largest x at which a single pixel is still on screen?
Question 2: A coin sits at world (1000, 250) and the camera is at (300, 0). Where is the coin drawn on screen?
Question 3: In an 800 ร 600 window, what are the math-style coordinates of the screen point (400, 100)?
Question 4: The camera is at (500, 300) and the player clicks at screen (50, 20). Which world point did they click?
Question 5: Why does the follow camera clamp cam_x to between 0 and WORLD_W - WIDTH?
๐ Going Further
- Mini-map: draw a small copy of the world in a corner of the Pan, Click, Convert window at one-tenth scale (multiply world positions by 0.1), with a rectangle showing what the camera sees, like the demo's mini-map.
- Zoom: add a
zoomnumber changed by the mouse wheel (from Keyboard, Mouse & Gamepad). Use(world_x - cam_x) * zoomto draw andscreen_x / zoom + cam_xfor clicks, and scale the marker radius too. - Edge scrolling: pan the camera when the mouse is within 20 pixels of a window edge, as many strategy games do. Use
pygame.mouse.get_pos()(state) every frame. - Read the docs: the pygame-ce page for pygame.Rect lists attributes such as
right,bottomandcenter. Find two you haven't used yet. - Coming up in Game Dev II: Intermediate: Tile Maps and Cameras build smooth follow cameras, dead zones and isometric grids on the same world-to-screen idea.