Files
battery/README.md
James Campbell d84e4627c3 Initial commit
2026-07-21 19:36:02 -04:00

11 KiB

Dying Phone

A single-screen 2D platformer in the spirit of the original Super Mario Bros., built with Python + pygame. Your phone is dying; sprint to the charger at the end of each level before the battery hits zero. The catch: the level is packed with obvious and (mostly) hidden traps. Touch one and you respawn instantly at the start with every trap reset. The battery is just the story's excuse for a timer — running it down kills you the same as any spike.

Install & run

pip install -r requirements.txt
python main.py                       # play every level in ./levels
python main.py levels/level1.yaml    # play a specific level (or several)
python main.py --debug               # force the debug view on for all levels

No art required — everything renders as labeled colored rectangles until you drop PNGs into assets/ (see assets/README.md).

Tests

pip install -r requirements-dev.txt
pytest

The suite runs headless (SDL dummy driver) and covers the physics, collision resolution, every trap and trigger, mounting, crush/shove interactions, and the game-flow states. See SPEC.md for a full description of the engine and level format.

Controls

Key Action
A / D (arrows) move left / right
Space / W jump (hold = higher, tap = short hop)
S + Space drop through one-way platforms
R give up and respawn
F5 hot-reload the level from disk (counts as a death; also escapes soft-locks)
Esc quit
Enter advance on the level-complete screen

Writing levels

Levels are plain YAML. A level has an ASCII map for static geometry and a traps list for the nasty interactive bits.

name: "My Level"
tile_size: 32          # pixels per tile (optional; default 32)
battery_seconds: 40    # time limit (optional)

map: |
  .........
  ...###...
  .P.....G.
  #########

traps:
  - type: spike
    at: [4, 2]         # [col, row], 0-based from top-left
    direction: up
    trigger: { within: 2.0 }

Map legend

Char Meaning
# solid block
- one-way platform — stand on top, jump/drop through. Renders identically to a solid block, so it's a hidden mechanic.
P player spawn
G goal / phone charger
. or space empty

The window is sized to the map automatically — one screen, no scrolling.

Level options (all optional, alongside name / map / traps):

Key Default Meaning
tile_size 32 pixels per tile
battery_seconds 45 time limit
battery_pct 12 how full the battery bar looks at the start (visual only)
debug false reveal everything normally hidden — a tile grid with col/row labels, faded - one-way platforms, tinted fake blocks / invisible walls / warps (with a line to their destination), dormant phase blocks, not-yet-sprung spikes, crumbled-block ghosts, and moving block paths. The --debug CLI flag forces this on for every level. Combine with F5 to iterate on a level without relaunching.

Trap reference

All traps take at: [col, row]. Where a trap draws its own block (fake, moving, patrol, crumble, shooter), leave that map cell empty — otherwise a real solid sits underneath it.

Any trap can also take:

  • invisible: true — stays fully functional but isn't drawn (revealed under the level's debug view). An invisible solid block is a wall you can't see.
  • count: [nx, ny] (or a single int for a horizontal line) — place a line/grid of copies, offset by spacing: [sx, sy] tiles (default 1). Only at is shifted per copy, so move is relative and works; an absolute path is shared. Handy for a row of spikes or a rectangle of (invisible) blocks.
type Key parameters Behavior
spike direction (up/down/left/right), trigger, delay Deadly spike; active while its trigger holds. The sprite auto-rotates to point the way direction faces.
block see below The all-in-one block: stationary, sliding, patrolling, deadly, fake, and/or crumbling.
arrow_shooter direction, speed, interval, trigger, delay A wall turret firing deadly arrows every interval seconds while its trigger holds.
warp to [col,row] Invisible tile that teleports the player to to on contact. Re-arms once you leave it.
phase_block trigger, fade Invisible and intangible until its trigger fires, then fades into a solid over fade seconds (and fades back out when the trigger releases). If you're standing in the cell the instant it starts appearing, you die — and if you die while one is mid-fade it snaps fully visible for the death freeze.

The block trap

One trap covers stationary blocks, sliders, patrolling platforms, spike blocks, fake blocks, and crumbling blocks — compose the behaviour from options (which combine, e.g. a moving platform that crumbles):

option meaning
path [[col,row],…] waypoints it travels between (default just [at] = stationary)
move [dcol,drow] shorthand for a 2-point path [at, at+move] (a slider)
mode once (default): advance to the last point while triggered, retreat to the first when not — the slider/dropper. loop / pingpong: cycle the whole path continuously (a patrol).
trigger when it moves (default always). A patrol is just a path + the default always trigger.
deadly (bool) true → a hazard (spikes) instead of a solid
fake (bool) looks solid but you fall straight through it
crumble (bool) + crumble_delay, respawn gives way crumble_delay s after you stand on it, vanishes, then reappears after respawn s (killing you if you're standing where it re-forms)
speed px/s
sprite override sprite (default: spike_block if deadly, fake_block if fake, crumble_block if crumble, else moving_block)
delay / release (once) hysteresis: the trigger must hold delay s to start extending and be clear release s (default 0.1) to start retracting — stops boundary jitter
sense home (default): a slider senses its trigger from its resting cell, so moving away can't toggle its own trigger. current: senses from the live position — for a block you ride (e.g. a dropper) so it stays put while ridden instead of pulling back
- type: block                     # stationary spike block
  at: [13, 4]
  deadly: true

- type: block                     # dropper: extends down while you're above it, rides with you
  at: [21, 9]
  move: [0, 4]
  sense: current
  trigger: { all: [ {within: 5}, {dir: above, aligned: true} ] }

- type: block                     # patrolling platform
  at: [9, 10]
  path: [[9, 10], [14, 10], [14, 6]]
  mode: loop
  sprite: patrol_block

A once block runs a committed stroke: once it starts moving it runs all the way to the endpoint (never reversing mid-stroke) and only reconsiders its trigger while parked — so a slider that moves out of its own sensor range can't buzz. Blocks track their live position, so a moving block can shove or crush you, and you can ride a non-deadly one.

Triggers

spike, block, and arrow_shooter are active while a trigger condition holds. A trigger is always (the default) or a condition object measured against the player each frame, relative to the trap's current position (so a moving trap's sensors follow it):

condition meaning
{ within: N } player within N tiles (radius)
{ dir: left|right|above|below } player is on that side
{ dir: …, range: N } …and within N tiles that way
{ dir: …, aligned: true } …and overlapping on the perpendicular axis (i.e. directly left / directly above)
{ timer: { interval: A, up_time: B } } cyclic: off A seconds, on B seconds
{ all: [ … ] } every listed condition (AND)
{ any: [ … ] } any listed condition (OR)
# drops only when you're close AND standing over it; keeps dropping as you ride it
trigger:
  all:
    - { within: 5 }
    - { dir: above, aligned: true }

delay (seconds, default 0): the trigger condition must hold continuously this long before the trap arms; leaving the condition resets the countdown. Handy for "linger and it strikes" spikes.

Getting crushed

You also die if a moving (non-deadly) block pinches you: presses you against a solid surface — squished into the ground from above, carried up into a ceiling, or ridden sideways into a wall. During the death pause the whole scene freezes, so the offending block stops on the spot until you respawn.

Mounting traps on other traps

Any trap can carry a mounts: list of child traps that ride along with it. A mounted trap's at is read as a relative offset (in tiles) from its parent, and it tracks the parent's live position every frame — so you can bolt a spike onto a patrolling platform, or a turret onto a moving block:

- type: block
  path: [[6, 10], [9, 10]]
  mode: pingpong
  sprite: patrol_block
  mounts:
    - type: spike
      at: [0, -1]          # one tile above the block; moves with it
      trigger: always
      direction: up

Everything the mounted trap does — hazards, solids, trigger sensing, drawing — follows the parent automatically; no special-casing per trap type. You can mount a block too (e.g. deadly blocks at the ends of a moving platform for spikes on a moving hazard) — a mounted trap rides rigidly at its offset and doesn't run its own motion, so mount stationary pieces (spikes, blocks, turrets) rather than something you expect to move on its own.

Adding a new trap type

Subclass Trap in game/traps.py, implement any of the hooks (solid_rects, oneway_rects, hazard_rects, carriers, update, draw, reset), and register the class in TRAP_TYPES. The engine polls those hooks every frame — nothing else needs to change.

Project layout

main.py            entry point
game/
  game.py          loop, states, HUD, death/respawn/win flow
  level.py         YAML -> geometry + traps
  player.py        movement & AABB collision
  traps.py         trap base class + all trap types + factory
  assets.py        PNG loading with colored-rect placeholders
  settings.py      physics/gameplay tunables
levels/*.yaml      level definitions
assets/*.png       optional sprites