Skip to content

Math, random & collision

The three modules every PicoPad game leans on for movement, spawning and hit detection. They are tiny, allocation-light, and import-only (no engine setup needed). For bare signatures see /reference/; this page is the why and when.

What it is: the small numeric helpers you reach for every frame - clamp/lerp/approach/wrap, turn-based trig (angles as 0..1 turns instead of radians), and 2D vector math (folded in from the old picogame_vec). Import it once as m and use it for smoothing, aiming, screen-wrap and distance.

When to use it: any time you would otherwise hand-roll a clamp, a smooth follow, or atan2. Reach for the _t trig when you want angles that wrap cleanly in [0,1) (rotation state, aim) rather than radians.

API:

  • clamp(v, lo, hi) - returns v pinned into [lo, hi]. The everyday helper for keeping a position on screen or HP in range.
  • mid(a, b, c) - median of three; behaves like a clamp when the middle arg is the value.
  • lerp(a, b, t) - linear blend a + (b-a)*t. With a small t each frame it gives a smooth follow/ease.
  • inv_lerp(a, b, v) - inverse of lerp: where v sits in [a,b] as 0..1. Returns 0.0 if a == b.
  • remap(v, a, b, c, d) - map v from range [a,b] onto [c,d]. Safe when a == b (maps to c).
  • sgn(x) - sign as -1, 0, or 1.
  • approach(v, target, step) - move v toward target by at most step, never overshooting. Great for friction/acceleration toward a value.
  • wrap(v, lo, hi) - wrap v into the half-open range [lo, hi). Degenerate or inverted ranges return lo (no division by zero, never out of range).
  • sin_t(turns) / cos_t(turns) - sine/cosine of an angle in TURNS (1.0 = full circle). Positive is clockwise on a y-down screen.
  • atan2_t(dy, dx) - angle of vector (dx, dy) in turns, normalized to 0..1. Use for aiming.
  • length(dx, dy) - magnitude of a vector.
  • distance(x1, y1, x2, y2) - distance between two points.
  • normalize(dx, dy) - unit vector; returns (0.0, 0.0) for a zero-length input.
  • angle_rad(dx, dy) - angle in RADIANS (raw atan2); from_angle_rad(a, mag=1.0) - vector of length mag at radian angle a.
  • TAU - the constant 2*pi, used internally by the _t trig.

Example (rotate a ship and thrust along its nose, as in asteroids):

import picogame_math as m
ang = 0.0 # heading, in turns 0..1
TURN = 0.01
ang = (ang + TURN) % 1.0 # rotate right
dx, dy = m.sin_t(ang), -m.cos_t(ang) # nose-up direction
vx += dx * 0.25
vy += dy * 0.25
sp = m.length(vx, vy) # current speed
x = m.clamp(x, 8, W - 8) # keep on screen

Gotchas:

  • The _t trig is clockwise on the y-down screen, and “up” is -cos_t - flip the sign of the y component, like the asteroids ship above, or your sprite turns the wrong way.
  • wrap is half-open: wrap(hi, lo, hi) returns lo, not hi. For an inverted or zero-width range it just returns lo rather than raising.
  • Use turns (sin_t/cos_t/atan2_t) and radians (angle_rad/from_angle_rad) consistently; do not mix the two for the same angle.

What it is: a tiny, seedable xorshift32 RNG plus the helpers small games actually need (weighted picks, in-place shuffle, a shuffle “bag”). Use it instead of the stdlib random when you want reproducible runs - replays, ghosts, daily-challenge seeds, or deterministic level layouts.

When to use it: create one Rand per game (seed it for reproducibility, or leave the seed off for a time-seeded run) and draw all randomness from it. Reach for Bag when you want fairness without streaks (spawns, Tetris-style pieces).

Rand(seed=None):

  • Rand(1234) seeds from an int (reproducible); Rand() seeds from the clock. seed=0 is remapped internally (xorshift cannot start at zero).
  • seed(s) - reseed an existing generator.
  • below(n) - integer in 0 .. n-1. Returns 0 if n <= 0.
  • randint(a, b) - integer in a .. b inclusive. Raises ValueError if b < a.
  • random() - float in [0.0, 1.0).
  • chance(p) - True with probability p (where p is 0..1).
  • choice(seq) - one element of seq. Raises ValueError on an empty sequence.
  • shuffle(lst) - Fisher-Yates shuffle of lst, in place (returns None).
  • weighted(weights) - return an index 0..len-1 picked proportionally to weights. Raises ValueError if the total is <= 0. No streak control (independent draws).

Bag(items, rng):

  • A shuffle-bag / “7-bag”: yields every item once per cycle in shuffled order, so no long streaks or droughts. Fairer than independent picks for spawns and pieces.
  • next() - return the next item, reshuffling automatically at the start of each cycle. Raises ValueError at construction if items is empty.

Example (one seeded RNG for the whole game, plus an anti-streak spawn bag):

import picogame_rand
rng = picogame_rand.Rand(0x1234) # seeded -> reproducible
x = rng.randint(40, W - 40) # 40 .. W-40 inclusive
if rng.chance(0.25):
spawn_powerup(x)
kind = rng.weighted([5, 3, 1]) # index 0 most likely
bag = picogame_rand.Bag([0, 1, 2, 3, 4, 5, 6], rng)
piece = bag.next() # every value once per cycle

Gotchas:

  • shuffle mutates the list in place and returns None - do not write lst = rng.shuffle(lst).
  • Bag takes ownership of a copy of items; next() reshuffles that internal list, so the order you passed in is not preserved.
  • weighted returns an index, not the value - index into your own list with it.
  • Same seed plus same call sequence equals same results: that is the point, but it also means an accidentally shared rng couples two systems’ randomness.

What it is: zero-allocation collision tests that read positions and sizes straight off sprites, so you stop hand-writing abs(a.x - b.x) < w and ... in every entity loop. They touch only the integer getters (sprite.x, sprite.y, bitmap.width, bitmap.height) and never sprite.anchor (whose getter allocates), so they are safe to call many times per frame. See /hardware/ for the per-frame budget this protects.

When to use it: inside your collision loops. Use hit for box overlap, hit_point to test a bare coordinate against a sprite, and is_within for cheap circular range checks (explosions, magnet pickups, proximity).

API:

  • hit(a, b, hw=None, hh=None) - axis-aligned box (AABB) overlap of two sprites. hw/hh are the half-extents of the combined box; default is half the sum of the two bitmaps’ sizes (a centre-overlap test). Returns a bool.
  • hit_point(a, px, py, hw=None, hh=None) - is point (px, py) within the sprite’s half-extents of its position? Default half-extents are half the sprite’s bitmap size.
  • is_within(a, b, r) - circular test: are the two sprites’ positions within radius r? Uses squared distance, so no sqrt.

Positions are treated as the sprites’ reference points: as long as both sprites share an anchor (both centre, or both top-left), the anchor offset cancels and these work regardless of which anchor you chose - see /scene-format/ for anchors.

Example (bullets vs rocks circular, player vs enemy boxed - adapted from asteroids and a platformer):

import picogame_collide as collide
for b in bullets:
for r in rocks:
if b.visible and collide.is_within(b, r, 18): # circular, no sqrt
kill(b, r)
if collide.hit(player, enemy, 12, 16): # explicit half-extents
take_damage()

Gotchas:

  • These read live sprite positions. If you track motion in floats (data["x"]), call sprite.move(int(x), int(y)) before testing, or the collision lags a frame behind the visible sprite.
  • hit is a box test, not pixel-perfect - tune hw/hh (often smaller than the art) so corners do not register false hits.
  • Default extents come from bitmap.width/height, so a sprite must have a bitmap set before a default-extent hit/hit_point call; pass explicit hw/hh otherwise.
  • Guard with if sprite.visible (and any “alive” flag) yourself - the helpers test geometry only, not whether an entity is active. See /memory/ on pooling freed entities.