Skip to content

Effects & feedback

These effects add visual feedback and movement without full-screen surfaces. Their memory costs differ: Fade draws through a StripDraw callback, Sky keeps a colour lookup table, Scanlines keeps one bitmap row, and palette helpers modify existing PAL8 palettes. See /reference/ for the signatures.

Reaching for feedback on a game event? This table routes you to the right effect; the sections below have the details.

Event Reach for
Small hit / took damage sprite flash (1-3 frames) + a small Shake (add(~0.15))
Enemy killed / big impact Particles burst + a bigger Shake (add(~0.6)) + hit-stop
Pickup / score pop a Tween pop (the blip is on the audio page)
Screen / scene change Fade
Menu / UI motion Tween

Create these helpers for a Scene and update the time-based ones once per game frame. Shake and InvertFlash emphasize an impact, Fade handles transitions, Tween smooths a changing value, Camera follows the world, and Sky or Scanlines draw raster-style backgrounds and overlays.

import picogame_fx as fx

Screen shake - a kick that decays

Shake stores an intensity called trauma. Calling add() raises it; each tick() applies a random offset and reduces the stored value. Pass the normal camera offset to tick() so both effects use one scene.set_view() call.

  • Shake(scene, max_offset=6, decay=0.03, seed=0x9E37) - max_offset is the peak pixel offset (about 6 suits 320x240; over 10 hides the action). decay is trauma lost per frame (about 0.03 reads as a “kick”, not a “rumble”).
  • .add(amount) - add trauma in the 0..1 range, clamped to 1.0. About 0.6 for a hit or explosion, 0.15 for a small bump. Trauma is squared before use, so small events barely shake and big ones slam.
  • .tick(cam_x=0, cam_y=0) - adds a decaying random offset on top of (cam_x, cam_y) and calls scene.set_view with the sum. Returns True while still shaking. Pass your camera offset here so shake and a moving camera don’t both call set_view and stomp each other.
import picogame_fx as fx
shaker = fx.Shake(scene, max_offset=6)
# ...on impact:
shaker.add(0.8)
# ...every frame (no camera, so feed 0,0):
shaker.tick(0, 0)

Freeze-framing on a big impact is a common juice technique, but there is no engine primitive for it - you skip N logic / scene.refresh() ticks yourself. Hold a freeze counter and, while it is positive, decrement and continue the loop (still calling clock.tick() so timing stays steady).

freeze = 0
# ...on a big impact:
freeze = 4
# ...at the top of the game loop:
if freeze > 0:
freeze -= 1
clock.tick()
continue

fx.Fade - dither screen fade / dim / flash

Section titled “fx.Fade - dither screen fade / dim / flash”

A dither fade to black and back

A StripDraw overlay stipples a colour over a rectangle with ordered Bayer dithering. It keeps no pixel surface. At level 0 its drawing rectangle collapses to 0×0, so it adds no region to repaint.

  • Fade(scene, width, height, x=0, y=0, color=0, cell=8) - covers the rect (x, y, width, height); defaults cover the whole screen. A sub-rect dims just that area (a panel behind a dialog, a sidebar). color=0 is black; cell is the dither block size. Added to the scene fixed=True so it ignores the camera.
  • .set(level) - jump instantly to level (0 = clear .. 16 = solid). Use 16 to start opaque before a fade-in.
  • .to(target, speed=2.0) - head toward target at speed levels per frame. Returns self.
  • .out(speed=2.0) / .into(speed=2.0) - shortcuts for to(16) (to opaque) and to(0) (to clear).
  • .dim(level=8) - jump to a partial hold, e.g. a 50% dim behind a menu. .clear() jumps to 0.
  • .pulse(level=12, speed=2.0) - ramp up to level then automatically back to 0; the smooth full-screen flash. Keep level under 16 so it stays a see-through dither, never a solid wall.
  • .tick() - step level toward target by speed. Returns True when the target is reached.
  • .is_done (property) - True when level == target.
import picogame_fx as fx
fader = fx.Fade(scene, W, H)
# ...trigger a fade to black:
fader.out(speed=2)
# ...each frame, fade back in once we hit black:
if fading and fader.tick():
fader.into(speed=2)

A value easing toward its target

A per-frame exponential ease-out for a single value: UI slides, pop-up scales, a number that should “catch up” smoothly. No keyframes or schedule.

  • Tween(value=0.0, speed=0.2) - speed is the fraction of the remaining gap closed each frame (0..1).
  • .to(target, speed=None) - set a new target (and optionally a new speed). Returns self.
  • .set(value) - snap value and target to value immediately.
  • .tick() - move value a speed fraction toward target and return the new value. Snaps exactly when within 0.01.
  • .is_done (property) - True once value equals target.
import picogame_fx as fx
y = fx.Tween(0)
y.to(100) # slide a panel down to y=100
# ...each frame:
panel.y = int(y.tick())

The camera following across a wider world

Tracks a world point and produces the scene view offset, centred and optionally clamped to a world size. Use it when the level is bigger than the screen.

  • Camera(scene, w, h, lerp=0.18, world_w=0, world_h=0) - w/h are the screen size; lerp is the follow smoothing per frame; world_w/world_h (if non-zero) clamp so the view never shows past the world edge.
  • .follow(tx, ty, snap=False) - move the camera centre toward (tx, ty) by lerp, or jump there with snap=True. Returns self, so you can chain.
  • .apply() - compute the offset and call scene.set_view directly. Allocation-free; returns None. Use when there is no shake.
  • .offset() - compute and return the offset as an (ox, oy) tuple (allocates). Feed this into Shake.tick(ox, oy) to compose the two.
import picogame_fx as fx
cam = fx.Camera(scene, W, 240, world_w=bounds_w)
# ...each frame:
cam.follow(player.x, 120).apply()

A per-scanline gradient drawn through StripDraw. It retains a lookup table of h wire-order colours, or 2 * h bytes, and redraws the requested rows when its region is repainted. Add it before the gameplay layers.

  • Sky(scene, x, y, w, h, top, bottom) - fills the rect, lerping each scanline from the top wire-RGB565 colour to bottom. Added fixed=True. Change .top/.bottom over time for a day-night cycle.
import picogame as pg
import picogame_fx as fx
sky = fx.Sky(scene, 0, 0, W, HORIZON,
pg.rgb565(60, 120, 240), pg.rgb565(200, 230, 255))

Darkens every Nth row for a CRT or LCD-grid look. It retains one PAL8 row of w bytes plus a two-entry palette, then blits that row through StripDraw. Add it after gameplay layers so it remains visible.

  • Scanlines(scene, x, y, w, h, step=2, dark=pg.rgb565(0, 0, 0)) - step=2 darkens every other line; dark is the overlay colour. Precomputes a 1px dither row and blits it once per darkened line (one blit instead of a per-pixel loop).
import picogame_fx as fx
scanlines = fx.Scanlines(scene, 0, 0, W, H) # add LAST, on top of everything

fx.InvertFlash - controller inversion flash

Section titled “fx.InvertFlash - controller inversion flash”

Full-screen colour-inversion flash (sim-emulated)

Flips a compatible SPI panel to its negative for a few frames using controller colour inversion (pg.invert). It does not repaint the scene or allocate a pixel buffer. Use it with ST7789/ST7735-class controllers; it is not available on framebuffer outputs such as Fruit Jam DVI. The simulator emulates the effect.

  • InvertFlash(display, frames=3, normal=None) - display is e.g. board.DISPLAY; frames is the flash length. normal is the panel’s resting invert state. The PicoPad sends INVON in its init, so its resting state is normal=None (the default); pass normal=False only for a panel whose init does not invert.
  • .pulse(frames=None) - flip away from the resting state now (optionally for a custom frame count).
  • .tick() - count down and restore the resting state when the flash ends. Returns True while flashing. Call it after scene.refresh() so the INVON/INVOFF is the frame’s last bus op.
import board
import picogame_fx as fx
flash = fx.InvertFlash(board.DISPLAY, frames=6)
# ...on hit:
flash.pulse()
# ...after scene.refresh():
flash.tick()

Seizure safety: for any full-screen flasher (InvertFlash, Fade.pulse, rapid sprite flash), avoid sustained flashing above ~3 Hz (at least 10 frames apart at 30 fps); keep full-screen inverts to 1-3 frames, one-shot.

A particle burst expanding and fading

pg.Particles is a core engine layer for sparks, explosions, pickup bursts, and dust. It pre-allocates a fixed-capacity pool and does not create new particle objects during emit() or tick().

  • pg.Particles(capacity, *, size=1, gravity=0.0, fade=False) - a pool of up to capacity dots, each size px. gravity pulls them down per tick (0 = free drift); fade=True dims a dot as it ages. size/gravity/fade are keyword-only (pass them by name).
  • .emit(x, y, count, speed=1, life=30, color=0xFFFF) - burst count dots from (x, y) with random velocity up to speed px/tick, each living life ticks.
  • .tick() - age and move the live dots; call once per frame.
import picogame as pg
ps = pg.Particles(180, size=2, gravity=0.0, fade=True)
scene.add(ps) # add once, like any layer
# ...on a hit / kill / pickup:
ps.emit(x, y, 16, 4, 24, pg.rgb565(255, 210, 120)) # 16 sparks, speed 4, 24-tick life
# ...every frame:
ps.tick()

Palette cycling - a reserved colour band flows

PAL8 art can change colour without duplicating its pixel indices. cycle rotates a range of palette entries, swap copies another palette, and fade interpolates entries toward a target. Palette entries are wire-order RGB565 integers returned by pg.rgb565().

import picogame_palette as palette

  • snapshot(palette) - return an array('H') copy of a palette. Save the original once, before any fading or cycling, so you can fade relative to it or restore it.
  • restore(palette, base) - copy base back into palette in place.
  • cycle(palette, lo, hi, step=1) - rotate entries [lo..hi] inclusive by step (wraps), in place with no allocation, so it is safe to call every frame. Reserve a run of indices for flowing colours, paint your art with them, and they animate.
  • swap(dst_palette, src_palette) - copy one palette over another (up to the shorter length). GBC-style recolour: keep one PAL8 bitmap and hand it a different palette per variant. Cheaper than a second bitmap.
  • fade(palette, base, t, target=0, skip=None) - lerp every entry of palette from the saved base toward the target wire colour by t (0.0 = base .. 1.0 = target). target=0 (black) fades out; a white target fades to white. skip leaves one index untouched (e.g. a transparent index).
import picogame_palette as palette
# ...each frame, flow a reserved band of water colours:
palette.cycle(water_bmp.palette, 1, 6)
water.touch() # tell the renderer the palette changed

Recolour a sprite brighter / to a new hue — what sprite.tint can’t do (multiply only darkens). Keep one PAL8 bitmap and give it a warm palette (e.g. a green enemy → a hot amber “elite”):

import array, picogame_palette as palette
WARM = array.array("H", [0, pg.rgb565(150, 40, 30), pg.rgb565(255, 130, 45),
pg.rgb565(120, 45, 25), pg.rgb565(255, 215, 130),
pg.rgb565(235, 90, 45), pg.rgb565(255, 245, 190)]) # one entry per index
palette.swap(enemy.bitmap.palette, WARM) # copy WARM into the bitmap's palette, in place
enemy.touch() # the renderer won't notice a palette change on its own
# Per-variant instead of in-place? Build a 2nd `pg.Bitmap(DATA, ..., palette=WARM)` and assign it to
# the sprite's `.bitmap` — see examples/picogame_picowing.py, which recolours the Kenney enemy this way.