Modeling Systems

Classes and Games


Learning Objectives

  • You can give moving objects independent state and explicit operations.
  • You can create one object through another object’s method.
  • You can coordinate objects through composition and resolve each collision once.
  • You can export and restore an object model through independent plain records.
  • You can test a game with fixed steps separately from its graphical caller.

Part 5’s Word Practice connected file functions to a supplied Pyxel interface. Asteroids now gives you responsibility for the game model itself.

Turn the spaceship, fire bullets, and clear a field of moving asteroids before the ship loses its three health points. Each asteroid has one size and a constant velocity. A bullet moves for a limited number of steps. Objects wrap around the playfield when they cross an edge.

The controls, drawing, starting field, collision geometry, and file operations are supplied. You will implement how individual objects behave and how the game coordinates their interactions. This version keeps the mechanics small: the ship stops when you release Up, and asteroids disappear when hit without splitting into smaller pieces.

Both programming exercises include a Pyxel window. Run main.py in the Inline Editor to open it. Left/Right turn the ship, Up moves it, and Space fires. P pauses or resumes; R restarts. The first exercise is a flight-practice scene for trying your moving objects. The second adds collisions, score, health, and saved games.

Start with One Moving Object

An asteroid stores its position and velocity. The position describes where it is now. The velocity describes how far each coordinate changes during one tick, a fixed step of the simulation.

Predict the next position:

Situationxyvxvy
Initially20302-1
After one move()22292-1
After another move()24282-1

Screen coordinates increase to the right and downward. A negative y velocity therefore moves upward. The supplied playfield is 240 pixels wide and 160 pixels high. The expression (self.x + self.vx) % WIDTH wraps x at either edge; use the same pattern for y and HEIGHT.

Each asteroid owns its coordinates. Moving one instance must leave another instance unchanged, even when they started with equal values.

Give Bullets a Lifetime

A bullet also stores position and velocity, plus a remaining lifetime. Its move() advances its position and subtracts one from lifetime while lifetime is positive. Once lifetime is zero, further calls leave it unchanged.

is_expired() answers a question about that state. hits(asteroid) answers another: does a live bullet touch this asteroid? Use the supplied overlaps helper for the geometry. It handles touching circles and objects near opposite edges of the wrapped playfield.

The query does not remove anything. An individual bullet does not own the game’s asteroid collection.

A Spaceship Can Create a Bullet

The ship’s fire() method returns a new Bullet. It uses the ship’s current position and the velocity returned by velocity_for(self.angle, BULLET_SPEED). The helper supplies the direction calculation.

For a ship facing up, the following calls should leave the ship still and move only the first shot:

ship = Spaceship(100, 80, angle=0)
first = ship.fire()
second = ship.fire()
first.move()
assert first.y == 76
assert first.lifetime == 44
assert second.y == 80
assert ship.y == 80
assert first is not second

The first exercise supplies initializers, record methods, ship controls, and geometry. Complete five methods in objects.py, then extend check.py with an expired bullet and a position that crosses an edge.

Model moving objects

0 / 20 points

Complete the five marked methods in objects.py: Asteroid.move, Bullet.move, Bullet.is_expired, Bullet.hits, and Spaceship.fire. Constructors, ship controls, record exports, geometry, and a Pyxel flight-practice scene are supplied. The next Asteroids exercise supplies completed objects, so you do not need to copy this submission.

Each asteroid and bullet has x, y, vx, and vy attributes. Positions use pixels: x increases to the right and y increases downward. One call to move() advances one fixed tick. The playfield is WIDTH by HEIGHT; (position + velocity) % size wraps a position at either edge.

  • Asteroid.move() updates its own x and y using its current velocity and the supplied WIDTH and HEIGHT. Preserve velocity and radius. Return None.
  • Bullet.move() does the same when lifetime > 0, then subtracts exactly one from lifetime. At lifetime zero, change nothing. Return None.
  • Bullet.is_expired() returns a boolean saying whether lifetime is zero, without changing anything.
  • Bullet.hits(asteroid) returns False for an expired bullet. Otherwise return the result of the supplied overlaps(self, asteroid). The helper handles circle boundaries and wrapping; touching edges counts as a hit. This query must not move or remove either object.
  • Spaceship.fire() calls velocity_for(self.angle, BULLET_SPEED), then returns a new Bullet at the ship’s current x and y with that velocity and the default lifetime of 45 ticks. Leave the ship unchanged. Each call creates an independent bullet. The game will decide whether firing is allowed and where to store it.

For example, a ship at (100, 80) with angle 0 faces up. Its bullet starts at (100, 80) with velocity (0, -4). After one move, the bullet is at (100, 76) with lifetime 44. The ship remains at (100, 80).

Inputs and current attributes satisfy the supplied initializers’ rules. Read current attributes on each call. Use the supplied helpers; you do not need to derive collision geometry or edit rules.py. These objects do not print, ask for input, use graphics, or access files. Separate instances remain independent.

Try your moving objects

Open Show file tree, select main.py, and choose Run to open the flight-practice window. Press P to start or pause. Left/Right turn the ship, Up moves it, and each Space press creates a bullet. Releasing Up stops the ship. R restarts the scene in its paused state.

Watch the asteroids wrap across the edges. Fire several shots to see that bullets move independently. The scene shows the latest bullet’s position and remaining lifetime, and whether it touches the marked target. Targets remain in the scene; there is no asteroid destruction or score in this exercise.

The scene uses your methods. If an invoked method is unfinished or raises an error, the scene pauses and displays the method or error. Complete or fix the method, then run main.py again. You do not need to pass the checks before opening the window.

Check the object methods

Select check.py and choose Run as Python to run the sample checks without opening Pyxel. Add cases for movement across each edge, the final lifetime tick, an expired bullet touching an asteroid, and two shots from one ship.

Keep the supplied graphics in pyxel_graphics.py and game_drawing.py, the helpers in rules.py, and the launch in main.py. No edits to those files are required. Submit all supplied files for grading against the full contract. Grading checks your object methods; playing the scene is for observing their behavior.

Run main.py and press P to try your objects in flight practice. Fire several bullets to see them move independently. Watch the latest shot’s position and remaining lifetime. Aim at the marked target to see the result of hits. Targets stay in the scene: this exercise does not yet remove asteroids or keep score. If an invoked method is unfinished, the scene pauses and names the method. Complete it and run the program again.

To run the sample checks, open Show file tree, select check.py, and choose Run as Python. This shows the check output without opening a Pyxel window. The checks and the preview both use your methods; passing every check is not required to open the preview.

Let the Game Coordinate the Objects

The next exercise supplies completed moving objects, so it does not depend on copying your previous submission.

Object or moduleResponsibility
SpaceshipStore position, direction, and health; move, receive damage, and create bullets
BulletMove, track remaining lifetime, and answer hit queries
AsteroidMove at its current velocity
GameOwn the objects and collections; allow firing, resolve hits, and keep score
storage.pyRead and write JSON using the model’s records
AsteroidsApp in pyxel_graphics.pyRead keys, call the model, and draw its current state

This is composition. The game contains a ship and collections of bullets and asteroids. Each object’s methods perform work on that object’s state.

Game.fire() has a different responsibility from Spaceship.fire(): it checks whether the game is active, asks its ship to create a bullet, and appends that returned object. It returns True for an accepted shot and False while paused or after the game ends. Firing does not itself advance time.

Resolve Each Hit Once

The supplied Game.step() advances one tick in this order:

  1. Move every asteroid, then every bullet.
  2. Remove expired bullets and resolve collisions between bullets and asteroids using your method.
  3. Resolve collisions between the ship and asteroids using the supplied method.
  4. Pause if the ship has no health or no asteroids remain.

A bullet whose lifetime reaches zero during movement cannot score on that tick. Bullet hits are resolved before ship contacts, so destroying an asteroid can protect the ship from it.

Process bullets in list order. For each live bullet, find the first remaining asteroid its hits method accepts. Remove that one asteroid and the bullet, and add 10 to the score. A missed bullet survives. Two bullets aimed at one asteroid must not award points twice.

Recall Part 2’s warning about changing a list’s length while traversing it. Keep the search and removal in this order:

  1. Build a separate list for surviving bullets. For asteroids, start with remaining = self.asteroids.copy(). This copies the list while retaining references to the existing asteroid objects.
  2. For each live bullet, search remaining in order for the first asteroid its hits method accepts. End the inner search loop before removing anything.
  3. If the search found hit, use remaining.remove(hit) to remove that asteroid from the separate list. Later bullets then search only the asteroids still present.
  4. Keep missed bullets in the survivor list. Preserve the order and identity of survivors in both lists.

Predict the Survivors

Before implementing collision resolution, work through these two separate situations. Each starts with score 0. All bullets are live, and the stated hits have already been determined. This is a call to resolve_bullet_hits() alone, so nothing moves. B1, B2, A1, and A2 label existing objects; the lists show their processing order.

SituationBulletsAsteroidsHits
Two bullets reach one asteroid[B1, B2][A1]Both bullets hit A1
One bullet reaches two asteroids[B1][A1, A2]B1 hits both asteroids

For each row, predict the surviving bullet list, asteroid list, and score. Explain when the search stops and which objects the next search can still find.

In the first situation, B1 removes A1 and is removed too. B2 finds no remaining asteroid, so the result is bullets [B2], asteroids [], and score 10. In the second situation, B1 removes only the first matching asteroid, A1. The result is bullets [], asteroids [A2], and score 10. The survivors are the same instances as before the call.

Check One Tick

Use known positions and velocities in check.py. For example, place a stationary asteroid at (30, 20), a second asteroid at (200, 120) moving right by one, and a bullet at (16, 20) moving right by four with lifetime 3. Keep the ship at its default (120, 80) and resume the game.

The collision radii are 8 for an asteroid and 2 for a bullet. A distance of 10 counts as touching:

AfterFirst asteroidBulletSecond asteroidScore
First step()RemovedMoves to (20, 20), then hits and is removed(201, 120)10
Second step()Still absentStill absent(202, 120)10

Repeat with initial lifetime 1. The bullet reaches zero and is removed before testing hits, so the first asteroid remains and score stays zero.

No real-time wait or random input is needed. The same sequence of model calls gives the same result even when no graphical window is open.

Play Asteroids

Complete the next exercise in stages. Start with firing and collision resolution; these two methods make the full game playable. Snapshots and restoration add saving and loading afterward.

Complete this stageRun this supplied checkPoints
firecheck_firing()5
resolve_bullet_hitscheck_collisions()15
to_recordcheck_snapshots()5
restore and the complete gamecheck_restoration()10

At the bottom of check.py, replace the run_checks() call with the check named for your current stage. Select that file and choose Run as Python. Submit after each stage to receive credit for its completed behavior. Firing, collisions, and snapshots can earn their points while later methods are unfinished. The last 10 points require restoration to work with the earlier methods and the supplied save/load and graphical code.

Asteroids: coordinate the game

0 / 35 points

Complete four methods of Game in models.py: fire, resolve_bullet_hits, to_record, and restore. The starter supplies completed Asteroid, Bullet, and Spaceship classes in objects.py, movement and collision geometry in rules.py, save validation in records.py, JSON operations in storage.py, and the Pyxel interface in pyxel_graphics.py and game_drawing.py. The short main.py launches the game. Preserve these components’ behavior.

This version has one asteroid size, constant-speed movement, bullets lasting at most 45 ticks, and a fixed starting field. Objects wrap around the playfield. Clear the asteroids before the ship’s three health points run out. There is no acceleration, asteroid splitting, or random spawning to implement.

A game owns ship, its own asteroids and bullets lists, integer score, and boolean paused. New games start paused. The supplied step() returns False without change while paused or over. Otherwise it moves asteroids, moves bullets, calls your resolve_bullet_hits, then resolves ship collisions and returns True. A bullet whose lifetime reaches zero during movement expires before collisions. Ship collisions remove contacting asteroids without scoring and cost at most one health point per tick. The game ends and pauses when health reaches zero or no asteroids remain.

  1. fire() returns False without change if paused or is_over() is true. Otherwise call the existing ship’s fire() once, append that exact returned bullet, and return True. Preserve existing bullets and their order. This method does not move anything or advance a tick.
  2. resolve_bullet_hits() processes bullets in their current list order. Discard expired bullets using is_expired(). For each remaining bullet, use its hits(asteroid) method to find the first still-present asteroid it hits, in asteroid list order. Remove that bullet and that one asteroid and add exactly 10 to score. A bullet that hits nothing survives. Each asteroid can score only once and each bullet can destroy at most one asteroid. Preserve the order and identity of all survivors. Do not move objects, change their attributes or the ship, or change pause state. The method returns None and can also be called directly in a paused test. Build survivor lists instead of changing the list you are traversing; a separate list of remaining asteroids is useful.
  3. to_record() returns an independent dictionary with ship, asteroids, bullets, and score. Call each object’s to_record() method and build new lists in collection order. Export every object currently in the collections without advancing the game. Changing the returned data must not change the live game, and later movement must not change an earlier snapshot. Pause state is not saved.
  4. restore(document) first calls the supplied checked_state. Use the returned records to construct a new ship, new asteroids, and new bullets before replacing the current state. Restore score and pause the game. Return None. A rejected document must leave the current game and its objects unchanged. Successful restoration must create objects independent of the input document.

A ship record contains x, y, angle, and health. Asteroid records contain x, y, vx, and vy; bullet records also contain lifetime. The supplied checked_state checks the document’s structure and values, raising ValueError for invalid data, such as a negative lifetime. Use it as supplied; you do not need to write validation rules. Restore the saved state as it is: movement, expiry, and collisions belong to an active tick.

Model methods do not print, ask for input, read or write files, consult a clock, or use Pyxel. Independent games must not share mutable state.

Work in the same order as the chapter: firing, collision resolution, snapshots, then restoration. The supplied check.py has a check for each stage:

StageMethodCheck to runPoints
1firecheck_firing()5
2resolve_bullet_hitscheck_collisions()15
3to_recordcheck_snapshots()5
4restore and the complete gamecheck_restoration()10
Total35

Submit after each stage. A stage earns its points when all its grading checks pass; unfinished later methods do not prevent credit for firing, collisions, or snapshots. The final stage checks restoration together with the earlier methods, saving, loading, and the supplied graphical caller. The sample checks help you get started; the grader also uses other cases from the contract.

At the bottom of check.py, replace the run_checks() call with the check for your current stage. In the Inline Editor, open Show file tree, select check.py, and choose Run as Python to see the check output without opening Pyxel. Later methods can still be unfinished. The restoration check uses your completed snapshot method and includes a JSON round trip. Put run_checks() back once all four stages pass. Extend the collision samples with no hits and one bullet touching two asteroids.

Play the game

Select main.py and choose Run to open the Pyxel window. Firing and collision resolution must be complete for full gameplay; snapshots and restoration can remain unfinished. The sample checks run separately in check.py.

Press P to start or pause. Left/Right turn; holding Up moves at a constant speed; each Space press fires once. Releasing Up stops ship movement. R starts a fresh paused field without deleting the save. If an invoked model method is unfinished or raises an error, the game pauses and shows the method or error. Fix the method and run main.py again.

After completing to_record, save with S while paused. After completing restore, load with L while paused. An unfinished method is named in the status message. Loading successfully replaces the game and leaves it paused; a failed load preserves the current game. Saving and loading while running only show a request to pause.

Save while bullets are in flight, resume for a few ticks, pause, then load. Check positions, velocities, bullet lifetimes, health, and score. Browser saves are temporary: save and load within the same game execution. R keeps the save, but running the code again can remove generated files.

Check persistence in your IDE

You can also work in your IDE. Download with the IDE plugin or choose Download starter code in the File Submission tab. Extract the files into a separate exercise folder and use the Python and Pyxel setup from Part 5.

Run py check.py on Windows or python3 check.py on macOS/Linux; use python if that is your configured command. These model and file checks need only Python. Run main.py with the same command to play. Extend the checks with a damaged final bullet record that must leave the live game unchanged.

Save, close the game, run main.py again, and load. The local practice_asteroids.json survives restarts. This checks persistence beyond the single game execution available in the browser.

Submit in the Inline Editor, with the IDE plugin, or upload a ZIP in the File Submission tab. Include all supplied source files and your checks at the archive’s top level. Leave out generated saves, temporary test data, .venv, and __pycache__. The grader checks model behavior, delegation, snapshots, reconstruction, JSON round trips, and the graphical caller’s use of your model.

After completing firing and collisions, select main.py and choose Run. P starts or pauses the game. Left/Right turn the ship; Up moves it; each Space press fires. R starts a fresh paused field. Try clearing the asteroids before the ship loses its three health points. Saving and loading can remain unfinished while you play.

The supplied AsteroidsApp in pyxel_graphics.py registers screen.run(self.update, self.draw), where screen is the Pyxel module. These are bound methods, each retaining its application instance for a later call. Writing self.update() would call the method immediately instead of passing it.

Snapshot and Restore the Game

The save document describes objects using plain records:

{
"ship": {"x": 120, "y": 80, "angle": 0, "health": 3},
"asteroids": [{"x": 30, "y": 20, "vx": 0, "vy": 0}],
"bullets": [{"x": 16, "y": 20, "vx": 4, "vy": 0, "lifetime": 3}],
"score": 0
}

Game.to_record() calls each object’s record method and builds new lists. Changing a saved record must not change a live object, and later movement must not change an earlier snapshot. Velocities and remaining lifetimes matter as well as positions: they determine what happens next.

Game.restore(document) first calls the supplied checked_state validator. Build all replacement objects before changing the current game. A bad final bullet record must leave every existing object and list in place, just as a rejected library load preserved the current library in Objects and Persistent Data.

Successful restoration creates new objects and pauses the game so the player can inspect it before resuming. Pause state is a control decision here and is not stored in the document. Overlapping objects are valid saved data; collisions are resolved on the next active tick.

Save and Restore During Play

Complete to_record and restore in the same exercise. Run their stage checks with Run as Python. The restoration check uses your completed snapshot method and includes a JSON round trip. Once all four stages pass, put run_checks() back to check them together. Extend the samples with more positions, collision cases, and damaged records.

Run main.py to try saving and loading. While paused, S saves and L loads. Before the relevant method is complete, the control names the unfinished method and keeps the current game. R starts a fresh paused field without deleting the saved game.

Save with bullets in flight, resume, pause again, and load. Compare positions, health, score, and remaining lifetimes with your saved situation. In the Inline Editor, save and load during the same game execution; rerunning the code can remove generated files.

To check that a save survives closing and reopening the program, download the exercise into its own folder and use your IDE with Part 5’s Pyxel setup. Save, close the game, run main.py again, and load. The local practice_asteroids.json remains in the exercise folder.

Review what a saved document must contain and what a rejected load must preserve.

Check Your Understanding

  1. Why do two shots from the same ship need separate bullet instances?
  2. Why does Bullet.hits report a collision while Game removes objects and changes score?
  3. What prevents two bullets from scoring for the same asteroid?
  4. Why must expiry be checked after movement and before collision resolution?
  5. How can resolving bullet hits before ship contacts protect the ship?
  6. Why does a save need velocities and remaining lifetimes as well as positions?
  7. What must remain unchanged after a rejected restoration, and which tests still work with another interface?