Build a Python 3D City Chase
Build a 3D Python city game with walking, driving, collisions, and missions.
Introduction
30 Second Summary
A city game feels exciting when every street gives you somewhere to go. Recreating that feeling can seem far beyond a single Python file.
In this project, you will build a compact third-person city game in Python with Ursina. Your playable mission takes you from walking the streets to driving a car through a checkpoint.
What You'll Build
You will launch your own 3D city window where an orange character can jump into a blue car before racing toward a yellow checkpoint.
By the end of this project, you'll have:
- A city block you can explore with WASD controls. A third-person camera follows the orange character from above and behind.
- Collision detection that stops the character at buildings and boundary walls.
- A drivable blue car that switches the controls from walking to steering. The yellow checkpoint turns green when you complete the mission.
- Secret Mission: Add a red police car that chases the active player or vehicle. Getting caught displays Busted!.
Do I need any experience before starting?
You should feel comfortable writing Python functions that use conditionals, collections, or simple vector-style calculations.
Your Windows computer needs Python 3.12+ plus Visual Studio Code.
Before We Start
Before the hands-on work begins, lock in the compact city-chase experience you are building. This focused vertical slice gives every feature a clear role in one playable result.
Set Up the Ursina Project
Your compact city chase needs a dependable 3D window before any gameplay can matter.
A Python virtual environment keeps Ursina 8.3.0 isolated from your other projects. An orange-cube smoke test proves the engine can render before you add gameplay code.
In this step, get ready to:
- Prepare the python-city-chase workspace with its own .venv environment.
- Install the pinned Ursina release inside the active environment.
- Render one orange cube in an Ursina game window.
Create the project and virtual environment
Visual Studio Code treats an opened folder as a workspace. Its integrated terminal starts inside that folder.
- Press the Windows key to open Windows search.
- Type Visual Studio Code and press Enter.
You'll see Visual Studio Code open to its welcome screen or your most recent workspace.
The Windows folder picker is a little fiddly because you create the folder before selecting it as your workspace.
- Click File in the top menu bar.
- Click Open Folder....
- Select Desktop in the Windows folder picker.
- Click New Folder.
- Enter python-city-chase and press Enter.
- Click Select Folder to open the new folder as your workspace.
- Select Yes, I trust the authors if a workspace trust prompt appears.
The Explorer sidebar now shows python-city-chase as the workspace folder. Your project has a clear home on the Desktop.
- Click View in the top menu bar.
- Click Terminal.
The integrated terminal opens inside python-city-chase. On Windows, the default terminal profile is usually PowerShell.
- Select PowerShell from the terminal's Launch Profile dropdown if another shell is active.
- Create the .venv environment by running this command:
python -m venv .venv
What does this command do?
The venv module creates an isolated Python environment inside .venv. Packages installed there stay separate from your system-wide Python packages.
- Confirm that the .venv folder appears beneath python-city-chase in the Explorer sidebar.
Your project now has its own Python environment. Every game dependency can stay contained inside this workspace.
Missing the .venv folder?
Check that the terminal path ends with python-city-chase. Run the environment command again after correcting the terminal location.
If the command reports a Python problem, use help me diagnose why python -m venv .venv did not create the environment.
Activate the environment and install Ursina
Activation points the current terminal session at the Python executable inside .venv. The package installer then places Ursina inside the project-specific environment.
- Activate .venv by running this command:
.venv\Scripts\Activate.ps1
What does activation change?
The activation script updates the current terminal session to use Python from .venv. Future package installations now belong to this project.
The terminal prompt changes to indicate that the virtual environment is active.
Activation blocked by PowerShell?
PowerShell can block activation scripts under a restrictive execution policy. Change the policy only if activation was blocked.
- Allow locally created scripts for your Windows user by running this command in PowerShell:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
This updates the execution policy for your user account. Return to the VS Code terminal after the command finishes.
- Run the activation command above again in the VS Code terminal.
If activation is still blocked, use help me activate my .venv environment in the VS Code PowerShell terminal.
- Install Ursina 8.3.0 inside the active environment by running this command:
python -m pip install ursina==8.3.0
What does this install?
The pip package installer adds Ursina to the active virtual environment. The ==8.3.0 pin keeps every learner on the same engine release.
Choose the tab that matches the result shown in your terminal.
✔️ The pinned version installed
The installer output confirms Ursina 8.3.0 is installed. Your isolated game engine is ready for the smoke test.
ⓧ I see an older version
An older version means the active Python environment still has a different Ursina release.
- Replace the older package with the pinned release by running this command:
python -m pip install ursina==8.3.0
Why run the installer again?
The exact version pin tells pip to resolve Ursina 8.3.0 inside the currently active environment.
The completed installer output should name Ursina 8.3.0.
Still seeing the older version?
Confirm that .venv is active in the same terminal where you run the installer.
Use help me find why my virtual environment still uses an older Ursina version.
ⓧ Ursina is not installed
A missing package means the pinned installation did not finish in the active environment.
- Check that your computer has an active internet connection.
- Install the pinned package by running this command:
python -m pip install ursina==8.3.0
What should this change?
The installer downloads Ursina 8.3.0 into .venv. A successful completion means the package is available to main.py.
Installation still failing?
Confirm that the terminal is inside python-city-chase. Check that the virtual environment remains active.
Use help me fix my Ursina installation.
Build the smoke test
A smoke test checks the smallest complete application lifecycle. Ursina creates the application, renders one entity, and keeps the game loop running.
- Select the Explorer icon in the left Activity Bar.
- Click the New File... button in the Explorer.
- Enter main.py and press Enter.
- Paste this smoke test into main.py:
from ursina import *
app = Ursina()
Entity(
model='cube',
color=color.orange,
)
app.run()
What does this code do?
- The import makes Ursina's core classes available to main.py.
- Ursina() creates the game application.
- Entity adds an orange cube to the scene.
- app.run() opens the game window and starts the game loop.
- Save main.py by pressing Ctrl+S.
✔️ The full file matches
Your saved main.py is ready to run.
ⓧ I'd like to double check the full code
Compare your complete main.py with this reference:
from ursina import *
app = Ursina()
Entity(
model='cube',
color=color.orange,
)
app.run()
This starts a long-running game process. The terminal remains occupied until you close the game window.
Before you run it, what visual result do you expect from the smoke test?
- Run the smoke test from the active VS Code terminal with this command:
python main.py
What does this command do?
Python executes main.py. The application keeps running while the Ursina window is open.
You'll see a game window containing one orange cube. That first rendered object proves Ursina can import successfully and draw the scene.
Game window not opening?
Confirm that main.py is saved inside python-city-chase. Check that the terminal still uses .venv.
Compare your file with the full-code tab if Python reports a syntax or import problem.
Use help me troubleshoot the Ursina smoke test.
Your isolated Ursina project now renders a working 3D object. Next, you'll replace the smoke test with a city you can walk through.
Walk Through a Tiny City
Your working Ursina window proves the engine can render code from main.py. Movement now turns that test into a playable city.
This step builds the city block and connects keyboard input to the player each frame. You will test the first playable version against a building to learn how direct position changes behave.
In this step, get ready to:
- Build a compact city from primitive ground, road, building, and boundary entities.
- Move an orange player with frame-rate-independent WASD controls.
- Follow the player with the camera and test what happens at a building.
Build the city block
Each visible object is an Entity with a primitive cube model. Position controls where it sits in the scene. Scale turns the same cube into ground, roads, buildings, or walls.
- In Visual Studio Code, select main.py from the Explorer sidebar.
- Delete the existing orange cube Entity block between app = Ursina() and app.run().
- Add the city base below app = Ursina() by pasting this code:
PLAYER_SPEED = 6
ground = Entity(
model='cube',
position=(0, -0.5, 0),
scale=(44, 1, 44),
color=color.gray,
)
Entity(model='cube', position=(0, 0.01, 0), scale=(8, 0.02, 44), color=color.black)
Entity(model='cube', position=(0, 0.02, 0), scale=(44, 0.02, 8), color=color.black)
What does this code do?
- The ground entity creates a wide gray platform beneath the city.
- The two thin black entities cross at the center to form the roads.
- PLAYER_SPEED stores the movement speed that the player uses later.
- Save main.py.
- Switch back to the activated PowerShell terminal from the previous step.
- Render the new city base by running this command:
python main.py
What should I see?
You should see a wide gray platform with two black roads crossing through its center. The flat road cubes remain visible because they sit slightly above the ground.
Don't see the crossing roads?
Confirm that the new code sits above app.run(). Check that both road entities use color.black.
Help me find why the ground or roads are missing.
- Close the Ursina window.
The roads establish the open space where the player moves. Buildings turn the surrounding quadrants into a recognizable city block.
- Switch back to main.py.
- Add the four buildings below the two road Entity lines by pasting this code:
Entity(model='cube', position=(-13, 3.5, -13), scale=(8, 7, 8), color=color.azure)
Entity(model='cube', position=(13, 4.5, -13), scale=(8, 9, 8), color=color.orange)
Entity(model='cube', position=(-13, 5, 13), scale=(8, 10, 8), color=color.orange)
Entity(model='cube', position=(13, 3, 13), scale=(8, 6, 8), color=color.azure)
How do the buildings work?
- Each building uses a different vertical position to keep its base level with the ground.
- Each scale gives the building a distinct height.
- The alternating azure and orange colors make the four city quadrants easy to recognize while moving.
- Save main.py.
- Switch back to the activated PowerShell terminal.
- Render the buildings by running this command:
python main.py
What should I see?
You should see four tall buildings surrounding the crossing roads. Two buildings are azure. Two buildings are orange.
Are any buildings missing?
Count four building Entity lines below the roads. Confirm that every line has matching parentheses.
Help me fix the missing building.
- Close the Ursina window.
Boundary walls make the edges of the test area visible. Their long, thin shapes frame the city on all four sides.
- Switch back to main.py.
- Add the four boundary walls below the building entities by pasting this code:
Entity(model='cube', position=(0, 1, -22), scale=(44, 2, 0.5), color=color.black)
Entity(model='cube', position=(0, 1, 22), scale=(44, 2, 0.5), color=color.black)
Entity(model='cube', position=(-22, 1, 0), scale=(0.5, 2, 44), color=color.black)
Entity(model='cube', position=(22, 1, 0), scale=(0.5, 2, 44), color=color.black)
How do the boundaries frame the city?
- Two walls span the width of the ground at opposite z positions.
- Two walls span the depth of the ground at opposite x positions.
- Their black color separates the city edge from the gray ground.
- Save main.py.
- Switch back to the activated PowerShell terminal.
- Render the completed city layout by running this command:
python main.py
What should I see?
You should see four black walls marking the outer edge of the gray ground. The roads and four buildings remain visible inside them.
Does the city edge look incomplete?
Confirm that two walls use a width of 44. Confirm that the other two use a depth of 44.
Help me fix the city boundaries.
- Close the Ursina window.
Good progress. Your compact city now has roads, varied buildings, and a clear outer edge.
Add the player and movement
The orange player gives your input a visible target. Its position begins near the center road so you can reach every city quadrant.
- Switch back to main.py.
- Add the player below the final boundary Entity by pasting this code:
player = Entity(
model='cube',
position=(0, 1, -3),
scale=(1, 2, 1),
color=color.orange,
)
What does the player entity represent?
- The player name gives the movement code a specific entity to control.
- The tall scale creates a simple character shape.
- The starting position places the character above the ground near the road crossing.
- Save main.py.
- Switch back to the activated PowerShell terminal.
- Render the player by running this command:
python main.py
What should I see?
You should see a tall orange player near the central road. The player remains still because it does not have frame-by-frame movement yet.
Can't find the orange player?
Confirm that the player position is (0, 1, -3). Check that its color is color.orange.
Help me find the player entity.
- Close the Ursina window.
A game loop updates the scene once per frame. Ursina calls update() automatically while the game runs.
Why use delta-time movement?
The held_keys values show which movement keys are pressed during the current frame. Multiplying by time.dt scales each movement by the time since the previous frame.
This keeps the player's speed consistent when the frame rate changes.
- Add update() below the player entity by pasting this code:
def update():
x_input = held_keys['d'] - held_keys['a']
z_input = held_keys['w'] - held_keys['s']
player.position += Vec3(x_input, 0, z_input) * PLAYER_SPEED * time.dt
How does the movement work?
- The d and a keys produce horizontal input along the x-axis.
- The w and s keys produce forward or backward input along the z-axis.
- Vec3 combines both values into one movement direction.
- PLAYER_SPEED and time.dt convert that direction into frame-rate-independent movement.
- Save main.py.
- Switch back to the activated PowerShell terminal.
- Start the moving version by running this command:
python main.py
Test the movement
Press the WASD keys while the game window has focus. You should see the orange player change position while the city remains still.
Player not moving?
Click the game window before pressing the movement keys. Confirm that update() starts at the left edge of the file instead of inside the player entity.
Help me debug the WASD movement.
- Close the Ursina window.
Follow the player and test the city
A third-person camera keeps the active character visible while the city moves through the frame. The camera needs a position above the player plus a target to look toward.
- Find the player.position movement line inside update().
- Add these camera lines directly below it:
camera.position = player.position + Vec3(0, 10, -14)
camera.look_at(player.position + Vec3(0, 1, 0))
How does the camera follow?
- camera.position places the camera above the player with space behind it.
- camera.look_at() rotates the camera toward a point above the player's position.
- Both lines run every frame so the camera follows each movement.
- Save main.py.
✔️ Awesome, I've got everything!
Your main.py now contains the complete city, player movement, and following camera. You are ready to test the first playable version.
ⓧ I'd like to double check the full code
- Compare your saved main.py with this complete version:
from ursina import *
app = Ursina()
PLAYER_SPEED = 6
ground = Entity(
model='cube',
position=(0, -0.5, 0),
scale=(44, 1, 44),
color=color.gray,
)
Entity(model='cube', position=(0, 0.01, 0), scale=(8, 0.02, 44), color=color.black)
Entity(model='cube', position=(0, 0.02, 0), scale=(44, 0.02, 8), color=color.black)
Entity(model='cube', position=(-13, 3.5, -13), scale=(8, 7, 8), color=color.azure)
Entity(model='cube', position=(13, 4.5, -13), scale=(8, 9, 8), color=color.orange)
Entity(model='cube', position=(-13, 5, 13), scale=(8, 10, 8), color=color.orange)
Entity(model='cube', position=(13, 3, 13), scale=(8, 6, 8), color=color.azure)
Entity(model='cube', position=(0, 1, -22), scale=(44, 2, 0.5), color=color.black)
Entity(model='cube', position=(0, 1, 22), scale=(44, 2, 0.5), color=color.black)
Entity(model='cube', position=(-22, 1, 0), scale=(0.5, 2, 44), color=color.black)
Entity(model='cube', position=(22, 1, 0), scale=(0.5, 2, 44), color=color.black)
player = Entity(
model='cube',
position=(0, 1, -3),
scale=(1, 2, 1),
color=color.orange,
)
def update():
x_input = held_keys['d'] - held_keys['a']
z_input = held_keys['w'] - held_keys['s']
player.position += Vec3(x_input, 0, z_input) * PLAYER_SPEED * time.dt
camera.position = player.position + Vec3(0, 10, -14)
camera.look_at(player.position + Vec3(0, 1, 0))
app.run()
Before you run the game, what do you think will happen when the moving player reaches a visible building?
- Switch back to the activated PowerShell terminal.
- Launch the complete city by running this command:
python main.py
What happens while the game runs?
Ursina calls update() every frame. The player responds to held movement keys while the camera maintains its offset.
- Use W with A or D to walk toward a blue or orange building.
- Keep holding the movement keys after the player reaches the building wall.
You should see the camera stay above and behind the orange player. The player passes straight through the building even though the building remains visible.
That shortfall is intentional. Direct changes to player.position do not check whether an obstacle is ahead.
Can't reproduce the pass-through result?
Confirm that the player can move in both the x and z directions. Steer directly into the colored face of a building instead of following the black road around it.
Help me test the player movement.
- Close the Ursina window.
Your city is playable, and you have seen the movement gap firsthand. Next, you will make the buildings and boundaries stop the player.
Make the City Solid
Your Ursina city now has streets, buildings, boundaries, movement, and a following camera. The player still passes through every obstacle because the controller changes its position directly.
Direct position changes bypass collision response. In this step, a horizontal raycast checks the path before each move.
In this step, get ready to:
- Give the buildings, boundaries, ground, and player box colliders.
- Replace direct movement with a normalized, raycast-guarded controller.
- Confirm that the third-person camera tracks the solid player.
Give the city collision surfaces
A raycast can only report entities that have colliders. Box colliders give each solid cube a surface that the movement query can detect.
- In main.py, select everything from from ursina import * through PLAYER_SPEED = 6.
- Replace that selection with the following movement math setup:
from math import atan2, degrees, sqrt
from ursina import *
app = Ursina()
PLAYER_SPEED = 6
def flat_distance(first, second):
x_difference = first.x - second.x
z_difference = first.z - second.z
return sqrt(x_difference ** 2 + z_difference ** 2)
def add_building(x, z, width, depth, height, building_color):
return Entity(
model='cube',
position=(x, height / 2, z),
scale=(width, height, depth),
color=building_color,
collider='box',
)
What does this setup do?
- The math imports provide the square root, angle, and degree calculations used by the controller.
- The flat_distance() helper measures separation across the street plane.
- The add_building() helper creates each building with a box collider.
- Save main.py.
- Confirm that the new helpers load successfully by running this command in the VS Code terminal from earlier:
python main.py
What does this command do?
This starts the application defined in main.py. Reaching the game window confirms that Python loaded the new imports and helper functions.
You should see the city window open with the orange player ready to move. That is a useful checkpoint: the movement math now loads without an error.
Does the game window close immediately?
Return to the VS Code terminal to read the reported line number. Check the import names plus the indentation inside both helper functions.
Ask for help with the traceback.
- Close the Ursina game window.
- In main.py, select the scene code from ground = Entity( through the fourth colored building entity.
- Replace that selection with the following ground and building definitions:
ground = Entity(
model='cube',
position=(0, -0.5, 0),
scale=(44, 1, 44),
color=color.gray,
collider='box',
)
Entity(model='cube', position=(0, 0.01, 0), scale=(8, 0.02, 44), color=color.black)
Entity(model='cube', position=(0, 0.02, 0), scale=(44, 0.02, 8), color=color.black)
building_specs = (
(-13, -13, 8, 8, 7, color.azure),
(13, -13, 8, 8, 9, color.orange),
(-13, 13, 8, 8, 10, color.orange),
(13, 13, 8, 8, 6, color.azure),
)
for building_spec in building_specs:
add_building(*building_spec)
How are the buildings created?
- The ground gains a box collider that collision queries can detect.
- The building_specs collection stores the position, size, height, and color of each building.
- The loop passes each specification into add_building().
- Save main.py.
- Confirm that the refactored buildings render by running this command:
python main.py
What does this check prove?
This run creates all four buildings through add_building(). Seeing every colored building confirms that the specifications reached the helper correctly.
You should see four colored buildings in their original positions. Each building now has a box collider behind its visible cube.
Is a building missing?
Check that building_specs contains four tuples. Confirm that the loop passes each tuple into add_building() with the asterisk included.
Ask for help with the building loop.
- Close the Ursina game window.
- Select the four boundary entity lines plus the player = Entity( block immediately below them.
- Replace that selection with the following boundary and player definitions:
boundary_specs = (
(0, 1, -22, 44, 2, 0.5),
(0, 1, 22, 44, 2, 0.5),
(-22, 1, 0, 0.5, 2, 44),
(22, 1, 0, 0.5, 2, 44),
)
for x, y, z, width, height, depth in boundary_specs:
Entity(
model='cube',
position=(x, y, z),
scale=(width, height, depth),
color=color.black,
collider='box',
)
player = Entity(
model='cube',
position=(0, 1, -3),
scale=(1, 2, 1),
color=color.orange,
collider='box',
)
What makes the city solid?
- The boundary_specs collection preserves the position and scale of all four city limits.
- The loop creates every boundary with a box collider.
- The player also receives a box collider for consistent collision queries.
- Save main.py.
Before you test this version, do you expect box colliders to stop an update that still writes directly to player.position?
- Start the collider-enabled scene by running this command:
python main.py
What is this test isolating?
This version has collision surfaces throughout the city. Its existing update() function still applies movement without querying those surfaces.
- Walk the player directly into a building with the movement keys.
You will still pass through the building. The unchanged controller writes directly to the player's position without consulting the new colliders.
- Close the Ursina game window.
Guard every movement with a raycast
The controller needs to normalize its input before checking the path. Normalization keeps diagonal movement at the same speed as movement along one axis.
The raycast(...) query looks ahead from the player's center. Movement proceeds only when HitInfo.hit is false.
- In main.py, select the existing update() function from def update(): through the camera look-at line.
- Replace that function with the following guarded controller:
def update():
x_input = held_keys['d'] - held_keys['a']
z_input = held_keys['w'] - held_keys['s']
input_length = sqrt(x_input ** 2 + z_input ** 2)
if input_length > 0:
move_direction = Vec3(x_input / input_length, 0, z_input / input_length)
player.rotation_y = degrees(atan2(move_direction.x, move_direction.z))
obstacle = raycast(
player.position + Vec3(0, 0.5, 0),
move_direction,
distance=0.75,
ignore=[player],
)
if not obstacle.hit:
player.position += move_direction * PLAYER_SPEED * time.dt
camera.position = player.position + Vec3(0, 10, -14)
camera.look_at(player.position + Vec3(0, 1, 0))
How does the controller work?
- The input_length calculation measures the combined horizontal input.
- The normalized move_direction keeps diagonal movement from gaining extra speed.
- The angle calculation turns the player toward the current movement direction.
- The horizontal ray ignores the player itself. A clear result permits the position update.
- Save main.py.
✔️ Awesome, I've got everything!
Your colliders and guarded controller are saved in main.py. You are ready to test the complete movement loop.
ⓧ I'd like to double check the full code
from math import atan2, degrees, sqrt
from ursina import *
app = Ursina()
PLAYER_SPEED = 6
def flat_distance(first, second):
x_difference = first.x - second.x
z_difference = first.z - second.z
return sqrt(x_difference ** 2 + z_difference ** 2)
def add_building(x, z, width, depth, height, building_color):
return Entity(
model='cube',
position=(x, height / 2, z),
scale=(width, height, depth),
color=building_color,
collider='box',
)
ground = Entity(
model='cube',
position=(0, -0.5, 0),
scale=(44, 1, 44),
color=color.gray,
collider='box',
)
Entity(model='cube', position=(0, 0.01, 0), scale=(8, 0.02, 44), color=color.black)
Entity(model='cube', position=(0, 0.02, 0), scale=(44, 0.02, 8), color=color.black)
building_specs = (
(-13, -13, 8, 8, 7, color.azure),
(13, -13, 8, 8, 9, color.orange),
(-13, 13, 8, 8, 10, color.orange),
(13, 13, 8, 8, 6, color.azure),
)
for building_spec in building_specs:
add_building(*building_spec)
boundary_specs = (
(0, 1, -22, 44, 2, 0.5),
(0, 1, 22, 44, 2, 0.5),
(-22, 1, 0, 0.5, 2, 44),
(22, 1, 0, 0.5, 2, 44),
)
for x, y, z, width, height, depth in boundary_specs:
Entity(
model='cube',
position=(x, y, z),
scale=(width, height, depth),
color=color.black,
collider='box',
)
player = Entity(
model='cube',
position=(0, 1, -3),
scale=(1, 2, 1),
color=color.orange,
collider='box',
)
def update():
x_input = held_keys['d'] - held_keys['a']
z_input = held_keys['w'] - held_keys['s']
input_length = sqrt(x_input ** 2 + z_input ** 2)
if input_length > 0:
move_direction = Vec3(x_input / input_length, 0, z_input / input_length)
player.rotation_y = degrees(atan2(move_direction.x, move_direction.z))
obstacle = raycast(
player.position + Vec3(0, 0.5, 0),
move_direction,
distance=0.75,
ignore=[player],
)
if not obstacle.hit:
player.position += move_direction * PLAYER_SPEED * time.dt
camera.position = player.position + Vec3(0, 10, -14)
camera.look_at(player.position + Vec3(0, 1, 0))
app.run()
How should you use this reference?
Compare each section with your saved main.py. The order matters because the helper functions and scene entities must exist before update() uses them.
Verify the complete controller
The final check combines collision response, normalized movement, player rotation, and camera tracking. Each behavior should remain stable while you hold a movement key.
Before you run the game, where do you expect the orange player to stop when its movement ray reaches a building?
- Start the completed solid-city controller by running this command:
python main.py
What does this run test?
This starts the full frame-by-frame controller. Every movement attempt now produces a direction plus a short collision query.
- Use the movement keys to line the player up with a building.
- Hold W against the building for two seconds.
- Use the movement keys to line the player up with a black boundary wall.
- Hold W against the boundary wall for two seconds.
- Move diagonally with W plus D.
- Watch the camera while changing movement direction.
You should see the player stop at both the building and the boundary wall. The camera should continue following from above and behind.
Diagonal movement should match the speed of movement along one axis. The orange player should rotate toward each new direction.
That closes the collision gap: your city now behaves like a solid space instead of a collection of visible props.
Can the player still pass through something?
Check that the building or boundary entity includes collider='box'. Confirm that the ray uses the normalized move_direction.
Ask for help with the collision path.
Your character now moves through a solid city with a stable third-person camera. Next up, you will add a second control mode for driving a car.
Enter and Drive a Car
Your Ursina city now keeps the player inside solid streets. The next goal is to turn the blue car into a second way to move through that city.
Walking alone leaves the prototype in one control mode. A gameplay state lets the same controls switch between character movement and vehicle movement.
In this step, get ready to:
- Add a blue car with separate movement settings.
- Switch between walking and driving with the E key.
- Drive with steering and collision detection while the camera follows.
Add the car and driving state
Vehicle movement needs a higher speed plus a separate turning rate. The existing flat_distance() helper already measures proximity across the ground.
- In main.py, find PLAYER_SPEED = 6.
- Add the vehicle movement constants directly below it by pasting:
CAR_SPEED = 12
CAR_TURN_SPEED = 80
What do these constants control?
- CAR_SPEED gives the vehicle its faster movement rate.
- CAR_TURN_SPEED controls how quickly steering changes the vehicle's direction.
- In main.py, locate the closing parenthesis of the player entity.
- Create the blue vehicle below the player entity by pasting:
car = Entity(
model='cube',
position=(2, 0.5, 2),
scale=(1.8, 1, 3.8),
color=color.azure,
collider='box',
)
What does the car entity add?
- The wide cube shape makes the car visibly different from the upright player.
- The box collider gives future collision checks a solid vehicle shape to ignore or detect.
- Save main.py.
Before you run the game, where do you expect the blue car to appear relative to the orange player?
- Switch back to the VS Code terminal from earlier.
- Render the updated scene by running:
python main.py
What does this check prove?
This starts the current version of the game. A successful render confirms that Ursina can create the new vehicle entity.
You'll see the orange player near a blue car on the road. The car remains still because its controls come later in this step.
- Close the game window after confirming that the car is visible.
Don't see the blue car?
Check that the car entity sits after the complete player entity. Confirm that both closing parentheses remain in main.py.
Make sure the position is (2, 0.5, 2) so the car starts close to the player.
Help me fix the missing car
A small controls display keeps the current mode visible. The driving value starts as False because the player begins on foot.
- Add the controls display below the car entity by pasting:
hud = Text(
text='WASD: walk E: enter car',
position=(-0.85, 0.45),
scale=1.1,
background=True,
)
driving = False
How does the state display work?
- Text places the current controls on the game screen.
- driving records which movement mode should run.
- Save main.py.
- Check the controls display by running:
python main.py
What does this run check?
This reloads the scene with the new HUD. It also confirms that the initial walking state loads without changing the existing player controls.
You'll see WASD: walk E: enter car in the upper-left area of the game window.
- Close the game window after confirming that the controls are visible.
Missing the controls display?
Check that the full hud block appears after the car entity. Make sure Text starts with a capital letter.
Help me fix the HUD
Switch between walking and driving
The input(key) hook handles a single key press. It can use flat_distance() to accept E only when the player is close enough to the car.
- In main.py, locate the blank space above update().
- Add the entry and exit controls by pasting:
def input(key):
global driving
if key != 'e':
return
if driving:
driving = False
player.position = car.position + Vec3(2, 0.5, 0)
player.enabled = True
hud.text = 'WASD: walk E: enter car'
elif flat_distance(player, car) < 3:
driving = True
player.enabled = False
hud.text = 'WASD: drive E: exit car'
How does the mode switch work?
- The early return ignores every key except e.
- Entering sets driving to True when the player is less than three units from the car.
- Disabling the player hides the character while the vehicle is active.
- Exiting places the player beside the car before restoring the walking controls.
- Save main.py.
Before you test the switch, do you expect E to work from anywhere in the city or only near the car?
- Start the game by running:
python main.py
What does this test cover?
This run checks both branches of input(key). The HUD plus the player's visibility make each state change observable.
- Approach the blue car with WASD.
- Press E once while standing close to the car.
- Confirm that the orange player becomes hidden.
- Confirm that the HUD changes to WASD: drive E: exit car.
- Press E again.
- Confirm that the orange player reappears beside the car.
You'll see the HUD return to WASD: walk E: enter car after exiting. The blue car remains stationary until you add its movement branch.
- Close the game window after testing both modes.
Does E fail to switch modes?
Move closer to the blue car before pressing E. The proximity check only enters the vehicle when flat_distance(player, car) is below 3.
Check that def input(key): starts at the left edge of main.py instead of sitting inside another function.
Help me debug the E key
Drive with collision-aware controls
Vehicle movement combines throttle with steering. A raycast checks farther ahead because the car is longer than the player.
Reverse steering uses the opposite turning direction. This makes the car respond like a vehicle while backing up.
- In main.py, place the cursor on the first line inside update().
- Insert the driving branch at the start of update() by pasting:
if driving:
throttle = held_keys['w'] - held_keys['s']
steering = held_keys['d'] - held_keys['a']
if throttle != 0:
reverse_direction = 1 if throttle > 0 else -1
car.rotation_y += steering * CAR_TURN_SPEED * time.dt * reverse_direction
move_direction = car.forward * reverse_direction
obstacle = raycast(
car.position + Vec3(0, 0.25, 0),
move_direction,
distance=2.2,
ignore=[car, player],
)
if not obstacle.hit:
car.position += car.forward * throttle * CAR_SPEED * time.dt
else:
How does vehicle movement work?
- throttle reads forward or reverse input from W and S.
- steering reads turning input from D and A.
- reverse_direction flips the movement direction plus the steering direction while reversing.
- The longer collision ray stops the car before its front reaches a solid object.
- Indent the existing walking block once beneath else: with Tab.
- Leave the two existing camera lines at their current indentation level.
- Save main.py.
Before you run this version, which control mode do you expect update() to choose after you enter the car?
- Test the new movement branch by running:
python main.py
What does this run reveal?
This check proves that the driving state now selects a different movement branch. It also lets you test throttle plus steering before changing the camera target.
- Walk close to the blue car with WASD.
- Press E to enter the car.
- Hold W briefly to move forward.
- Hold S briefly to reverse.
- Press A or D while moving to steer.
You'll see the blue car move along its facing direction. Steering changes its direction only while forward or reverse throttle is active.
- Close the game window after confirming that the vehicle moves.
Car visible but not moving?
Check that the walking code is nested beneath else:. Confirm that the driving branch remains directly inside update().
Make sure car.position changes only inside the if not obstacle.hit: block.
Help me debug the car controls
The final camera target depends on the active state. Walking follows the player while driving follows the car.
- In main.py, locate the two camera assignments at the end of update().
- Replace both camera assignments with:
camera_target = car if driving else player
camera.position = camera_target.position + Vec3(0, 10, -14)
camera.look_at(camera_target.position + Vec3(0, 1, 0))
How does the camera choose a target?
- camera_target points to the car when driving is True.
- The same offset keeps both control modes framed from above plus behind.
- camera.look_at() keeps the active entity near the center of the view.
- Save main.py.
✔️ Awesome, I've got everything!
Great. Double check that main.py is saved before the final drive test.
ⓧ I'd like to double check the full code
Compare your complete main.py with this version:
from math import atan2, degrees, sqrt
from ursina import *
app = Ursina()
PLAYER_SPEED = 6
CAR_SPEED = 12
CAR_TURN_SPEED = 80
def flat_distance(first, second):
x_difference = first.x - second.x
z_difference = first.z - second.z
return sqrt(x_difference ** 2 + z_difference ** 2)
def add_building(x, z, width, depth, height, building_color):
return Entity(
model='cube',
position=(x, height / 2, z),
scale=(width, height, depth),
color=building_color,
collider='box',
)
ground = Entity(
model='cube',
position=(0, -0.5, 0),
scale=(44, 1, 44),
color=color.gray,
collider='box',
)
Entity(model='cube', position=(0, 0.01, 0), scale=(8, 0.02, 44), color=color.black)
Entity(model='cube', position=(0, 0.02, 0), scale=(44, 0.02, 8), color=color.black)
building_specs = (
(-13, -13, 8, 8, 7, color.azure),
(13, -13, 8, 8, 9, color.orange),
(-13, 13, 8, 8, 10, color.orange),
(13, 13, 8, 8, 6, color.azure),
)
for building_spec in building_specs:
add_building(*building_spec)
boundary_specs = (
(0, 1, -22, 44, 2, 0.5),
(0, 1, 22, 44, 2, 0.5),
(-22, 1, 0, 0.5, 2, 44),
(22, 1, 0, 0.5, 2, 44),
)
for x, y, z, width, height, depth in boundary_specs:
Entity(
model='cube',
position=(x, y, z),
scale=(width, height, depth),
color=color.black,
collider='box',
)
player = Entity(
model='cube',
position=(0, 1, -3),
scale=(1, 2, 1),
color=color.orange,
collider='box',
)
car = Entity(
model='cube',
position=(2, 0.5, 2),
scale=(1.8, 1, 3.8),
color=color.azure,
collider='box',
)
hud = Text(
text='WASD: walk E: enter car',
position=(-0.85, 0.45),
scale=1.1,
background=True,
)
driving = False
def input(key):
global driving
if key != 'e':
return
if driving:
driving = False
player.position = car.position + Vec3(2, 0.5, 0)
player.enabled = True
hud.text = 'WASD: walk E: enter car'
elif flat_distance(player, car) < 3:
driving = True
player.enabled = False
hud.text = 'WASD: drive E: exit car'
def update():
if driving:
throttle = held_keys['w'] - held_keys['s']
steering = held_keys['d'] - held_keys['a']
if throttle != 0:
reverse_direction = 1 if throttle > 0 else -1
car.rotation_y += steering * CAR_TURN_SPEED * time.dt * reverse_direction
move_direction = car.forward * reverse_direction
obstacle = raycast(
car.position + Vec3(0, 0.25, 0),
move_direction,
distance=2.2,
ignore=[car, player],
)
if not obstacle.hit:
car.position += car.forward * throttle * CAR_SPEED * time.dt
else:
x_input = held_keys['d'] - held_keys['a']
z_input = held_keys['w'] - held_keys['s']
input_length = sqrt(x_input ** 2 + z_input ** 2)
if input_length > 0:
move_direction = Vec3(x_input / input_length, 0, z_input / input_length)
player.rotation_y = degrees(atan2(move_direction.x, move_direction.z))
obstacle = raycast(
player.position + Vec3(0, 0.5, 0),
move_direction,
distance=0.75,
ignore=[player],
)
if not obstacle.hit:
player.position += move_direction * PLAYER_SPEED * time.dt
camera_target = car if driving else player
camera.position = camera_target.position + Vec3(0, 10, -14)
camera.look_at(camera_target.position + Vec3(0, 1, 0))
app.run()
How should you compare this file?
Check the constants first. Compare the car plus HUD next.
Finish with input(key) plus both branches of update().
Before you run the final check, do you expect the camera to follow the character or the car after you press E?
- Run the completed driving prototype with:
python main.py
What does the final check prove?
This run tests the complete state transition from walking to driving. It also verifies vehicle collision detection plus active camera targeting.
- Approach the blue car with WASD.
- Press E to enter the car.
- Drive forward with W.
- Reverse with S.
- Steer with A or D while moving.
- Hold W against a building.
- Press E again to exit the car.
You'll see the camera follow the blue car while driving. The car stops at buildings instead of crossing them.
The orange character reappears beside the car after you exit. That's the core GTA-style state switch working from entry to exit.
Camera or collisions behaving incorrectly?
Check that all three camera lines remain outside the walking and driving branches. They should use camera_target at the end of update().
Confirm that the car ray uses distance=2.2 plus ignore=[car, player].
Help me fix driving collisions or the camera
Your city now supports walking plus collision-aware driving from one set of controls. Next up, you'll add a checkpoint that turns this sandbox into a complete mission.
Complete a Checkpoint Mission
Your Ursina city now lets you switch from walking to driving. The prototype still behaves like a sandbox because the drive has no objective.
This step adds a checkpoint plus a one-time gameplay state. Reaching the checkpoint gives the drive a clear finish.
In this step, get ready to:
- Create a visible checkpoint objective on the far road.
- Show the objective in the HUD when driving begins.
- Trigger a one-time success state when the car reaches the checkpoint.
Add the checkpoint and mission state
A checkpoint turns the far road into a destination. The mission_complete variable records whether the objective has already been won.
- Locate the car = Entity( block in main.py.
- Insert this checkpoint block directly below the car block:
checkpoint = Entity(
model='cube',
position=(0, 0.08, 16),
scale=(4, 0.16, 3),
color=color.yellow,
)
What does this checkpoint add?
- The low vertical position keeps the checkpoint close to the road surface.
- The wide scale gives the car a clear target to drive across.
- The yellow color makes the objective visible from the following camera.
- Locate driving = False below the HUD.
- Replace that line with this two-line state block:
driving = False
mission_complete = False
Why track mission completion?
The initial False value keeps the objective available when the game starts. The mission logic changes it after the car reaches the checkpoint.
- Save main.py.
- Run the updated game from the existing terminal:
python main.py
What should you see?
You should see a low yellow checkpoint on the far section of road. This confirms that the new entity is part of the city scene.
Can't see the yellow checkpoint?
Confirm that the checkpoint block sits below the car block. Check that its position uses a positive z value of 16.
Check that color.yellow is assigned to the checkpoint.
Help me find my missing checkpoint.
- Close the game window to return to the terminal.
Point the driver toward the objective
The checkpoint creates a target. The player also needs a clear instruction when driving begins.
- Locate hud.text = 'WASD: drive E: exit car' inside the car-entry branch of input(key).
- Replace that line with this expanded driving prompt:
hud.text = 'WASD: drive E: exit car Reach yellow checkpoint'
Why change the driving prompt?
The expanded message connects the driving controls to the objective. It appears at the exact moment the player enters the car.
- Save main.py.
- Run the game again from the existing terminal:
python main.py
What does this run check?
This run starts the game with the updated car-entry message. The HUD changes when the player switches into driving mode.
- Walk to the blue car with the WASD keys.
- Press E to enter the car.
You'll see WASD: drive E: exit car Reach yellow checkpoint in the HUD.
Still seeing the old driving message?
Confirm that you changed the HUD assignment inside the branch that enters the car. The separate assignment in the exit branch should still restore the walking message.
Help me fix the driving HUD message.
- Close the game window to return to the terminal.
Complete the mission once
The final rule only runs during driving. It also checks that the mission is still open.
A short distance check triggers the success state when the car reaches the checkpoint.
- Locate the first line of update() in main.py.
- Add the state declaration directly below the function header so the top of the function matches this:
def update():
global mission_complete
Why declare the mission state as global?
The function assigns a new value to mission_complete. The global declaration connects that assignment to the shared state created above the function.
- Locate camera.look_at(camera_target.position + Vec3(0, 1, 0)) near the bottom of update().
- Insert the mission check directly below that camera line:
if driving and not mission_complete and flat_distance(car, checkpoint) < 2:
mission_complete = True
checkpoint.color = color.lime
hud.text = 'Mission complete! City Chase finished.'
How does the mission check work?
- The driving check prevents the walking player from completing the objective.
- The not mission_complete check lets the success logic run once.
- The flat_distance(car, checkpoint) < 2 check detects when the car reaches the target.
- The assignment to mission_complete locks in the win state.
- The color.lime assignment turns the checkpoint green.
- The final HUD assignment displays the mission-complete message.
- Save main.py.
✔️ Awesome, I've got everything!
Your main.py now contains the complete checkpoint mission.
ⓧ I'd like to double check the full code
- Compare your saved main.py with this complete version:
from math import atan2, degrees, sqrt
from ursina import *
app = Ursina()
PLAYER_SPEED = 6
CAR_SPEED = 12
CAR_TURN_SPEED = 80
def flat_distance(first, second):
x_difference = first.x - second.x
z_difference = first.z - second.z
return sqrt(x_difference ** 2 + z_difference ** 2)
def add_building(x, z, width, depth, height, building_color):
return Entity(
model='cube',
position=(x, height / 2, z),
scale=(width, height, depth),
color=building_color,
collider='box',
)
ground = Entity(
model='cube',
position=(0, -0.5, 0),
scale=(44, 1, 44),
color=color.gray,
collider='box',
)
Entity(model='cube', position=(0, 0.01, 0), scale=(8, 0.02, 44), color=color.black)
Entity(model='cube', position=(0, 0.02, 0), scale=(44, 0.02, 8), color=color.black)
building_specs = (
(-13, -13, 8, 8, 7, color.azure),
(13, -13, 8, 8, 9, color.orange),
(-13, 13, 8, 8, 10, color.orange),
(13, 13, 8, 8, 6, color.azure),
)
for building_spec in building_specs:
add_building(*building_spec)
boundary_specs = (
(0, 1, -22, 44, 2, 0.5),
(0, 1, 22, 44, 2, 0.5),
(-22, 1, 0, 0.5, 2, 44),
(22, 1, 0, 0.5, 2, 44),
)
for x, y, z, width, height, depth in boundary_specs:
Entity(
model='cube',
position=(x, y, z),
scale=(width, height, depth),
color=color.black,
collider='box',
)
player = Entity(
model='cube',
position=(0, 1, -3),
scale=(1, 2, 1),
color=color.orange,
collider='box',
)
car = Entity(
model='cube',
position=(2, 0.5, 2),
scale=(1.8, 1, 3.8),
color=color.azure,
collider='box',
)
checkpoint = Entity(
model='cube',
position=(0, 0.08, 16),
scale=(4, 0.16, 3),
color=color.yellow,
)
hud = Text(
text='WASD: walk E: enter car',
position=(-0.85, 0.45),
scale=1.1,
background=True,
)
driving = False
mission_complete = False
def input(key):
global driving
if key != 'e':
return
if driving:
driving = False
player.position = car.position + Vec3(2, 0.5, 0)
player.enabled = True
hud.text = 'WASD: walk E: enter car'
elif flat_distance(player, car) < 3:
driving = True
player.enabled = False
hud.text = 'WASD: drive E: exit car Reach yellow checkpoint'
def update():
global mission_complete
if driving:
throttle = held_keys['w'] - held_keys['s']
steering = held_keys['d'] - held_keys['a']
if throttle != 0:
reverse_direction = 1 if throttle > 0 else -1
car.rotation_y += steering * CAR_TURN_SPEED * time.dt * reverse_direction
move_direction = car.forward * reverse_direction
obstacle = raycast(
car.position + Vec3(0, 0.25, 0),
move_direction,
distance=2.2,
ignore=[car, player],
)
if not obstacle.hit:
car.position += car.forward * throttle * CAR_SPEED * time.dt
else:
x_input = held_keys['d'] - held_keys['a']
z_input = held_keys['w'] - held_keys['s']
input_length = sqrt(x_input ** 2 + z_input ** 2)
if input_length > 0:
move_direction = Vec3(x_input / input_length, 0, z_input / input_length)
player.rotation_y = degrees(atan2(move_direction.x, move_direction.z))
obstacle = raycast(
player.position + Vec3(0, 0.5, 0),
move_direction,
distance=0.75,
ignore=[player],
)
if not obstacle.hit:
player.position += move_direction * PLAYER_SPEED * time.dt
camera_target = car if driving else player
camera.position = camera_target.position + Vec3(0, 10, -14)
camera.look_at(camera_target.position + Vec3(0, 1, 0))
if driving and not mission_complete and flat_distance(car, checkpoint) < 2:
mission_complete = True
checkpoint.color = color.lime
hud.text = 'Mission complete! City Chase finished.'
app.run()
How is the finished file organized?
- The checkpoint block sits between the car and HUD entities.
- The mission state sits directly below the driving state.
- The checkpoint condition runs near the bottom of update() after the camera follows its target.
Before you run the finished game, do you expect walking across the checkpoint to complete the mission, or does the condition require the car?
- Run the finished checkpoint mission from the existing terminal:
python main.py
What does this command start?
This launches the saved checkpoint mission in its own game window.
- Walk to the blue car with the WASD keys.
- Press E to enter the car.
- Drive onto the yellow checkpoint with the WASD keys.
You'll see the yellow checkpoint turn green. The HUD displays Mission complete! City Chase finished.
That closes the loop: your city chase now has a playable objective with a visible win state.
Checkpoint not completing the mission?
Confirm that the checkpoint condition is inside update(). Its indentation should match the camera update lines.
Check that global mission_complete appears at the top of update().
Help me debug the checkpoint mission.
- Close the game window after capturing your finished mission.
Secret mission
Add a Police Pursuit
Your checkpoint run gains a moving threat. Add a red police car that follows the active character or vehicle and triggers a busted state when it catches you.
Clean Up Your Resources
Clean Up Your Resources
Your city chase runs entirely on your Windows computer, so it creates no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The local python-city-chase folder containing main.py.
- The .venv environment inside python-city-chase containing Ursina 8.3.0.
Keep everything running
Keeping the folder leaves your prototype ready for more experimentation. The local files create no ongoing charges.
- Leave python-city-chase in its current location.
- Keep .venv inside the project folder.
Pause - I'll come back to this later
Pausing closes running programs while preserving every project file. You can return without rebuilding the virtual environment.
- Close the Ursina game window if it is still running.
- Save main.py in Visual Studio Code.
- Close Visual Studio Code.
Delete - I don't want to use this again
Deleting the folder is permanent. Only this local prototype is affected.
- Close the Ursina game window if it is still running.
- Remove python-city-chase from its parent folder by running these commands in the existing PowerShell terminal:
cd ..
rmdir python-city-chase -Recurse -Force
ls
What do these commands do?
- The cd command moves PowerShell to the folder containing python-city-chase.
- The rmdir command removes the complete project folder.
- The ls command shows the remaining folders for verification.
- Confirm that python-city-chase is absent from the final directory listing.
Folder Still Present?
- Close the Ursina game window if it is still running.
- Move any separate terminal out of python-city-chase.
- Repeat the deletion command from the folder that previously contained python-city-chase.
Help me remove the remaining project folder.
That cleanup is handled. The entire city chase workspace is now off your computer.
Nice Work!
Nice Work!
You did it! You built a playable third-person city chase in Python with the Ursina engine.
You've learned how to:
- Create a compact 3D city from Python-built primitives. Frame the active player or car with a third-person camera.
- Implement frame-rate-independent movement with normalized input. Stop the player at buildings and boundaries with raycast collision detection. Protect vehicle movement with longer collision checks.
- Switch between walking and driving states while preserving the correct controls. Drive the car into a checkpoint objective to display the mission-complete state.
- Secret Mission: Add a police pursuit that follows the active target. Trigger a one-time busted state when the police car catches up.
Ready to quiz yourself?