Skip to content

Effects & juice

The “juice” layer: screen shake, dither fades, smooth tweens, follow cameras, raster backgrounds, and palette tricks. All of it is pure Python on top of the engine - no firmware change - and most of it costs near-zero RAM because it draws per-scanline via pg.StripDraw or mutates a palette instead of pixels. For the bare signatures see /reference/; this page is the per-symbol behaviour and the gotchas.

Drop-in effect helpers you instantiate against a Scene and tick() each frame. Reach for these when a game works but feels flat: a hit needs to land harder (Shake, InvertFlash), a transition needs to breathe (Fade), a value needs to ease instead of snap (Tween), the world is bigger than the screen (Camera), or the background wants depth (Sky, Scanlines).

import picogame_fx as fx

A decaying shake that composes with your camera instead of fighting it. Bump trauma on impacts, then tick() every frame.

  • 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)

Gotchas: Shake owns scene.set_view while it runs - if you also move a camera by hand, feed that offset into tick(cam_x, cam_y) rather than calling set_view separately. See /hardware/ for the screen size you are tuning max_offset against.

fx.Fade - dither screen fade / dim / flash

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

A StripDraw overlay that stipples a colour over a rect with an ordered (Bayer) dither - no alpha blending, classic 1-bit/Game Boy look. Idle it collapses to a 0x0 rect, so it costs nothing on the dirty-rect renderer.

  • 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)

Gotchas: level runs 0..16, not 0..255 or 0.0..1.0. A white Fade pulsed quickly (fx.Fade(scene, W, H, color=pg.rgb565(255, 255, 255)).pulse()) is a cheap hit-flash, but InvertFlash below is even cheaper.

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())

Gotchas: ease-out only, so it never overshoots or bounces. tick() returns the value - read its return rather than .value if you want the freshly-stepped number.

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()

Gotchas: apply() and Shake both call scene.set_view, so don’t run both blind. To combine, take ox, oy = cam.follow(...).offset() and pass those into shaker.tick(ox, oy) so shake stacks on the camera. See /scene-format/ for what set_view does to the view.

A per-scanline gradient band (sky, backdrop, day-night) drawn via StripDraw with zero RAM - no buffer. Add it first; it is a background layer.

  • 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))

Gotchas: it draws a fill_rect per scanline, so keep the band height sane; it is cheap but not free on a tall full-screen gradient.

Darkens every Nth row for a CRT/LCD-grid look. StripDraw, zero RAM. Add it last so it sits on top.

  • 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

Gotchas: order matters - add it after every gameplay layer or it gets painted over.

Flips the whole panel to its negative for a few frames using the controller’s hardware colour inversion (pg.invert). No StripDraw, no buffer, no repaint - cheaper than a Fade. Needs an ST7789/ST7735-class panel; on the simulator it is a silent no-op.

  • InvertFlash(display, frames=3, normal=True) - 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=True (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()

Gotchas: get normal right or the panel sticks inverted - on the PicoPad keep the default normal=True. The effect only exists on real hardware; see /hardware/ for which controllers support INVON/INVOFF.

Cheap colour effects on PAL8 art by mutating the palette, not the pixels - the classic Game Boy trick. Reach for it for animated water/lava (cycle), recolouring a shared bitmap for player 1/2 or normal/frightened (swap), or a smooth brightness/colour fade (fade) - all for a handful of array writes and zero extra art. Palette entries are wire-order RGB565 ints (what pg.rgb565() returns and what a pg.Bitmap carries as its array('H') palette).

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

Gotchas: the dirty-rect renderer reads the palette at blit time but does not notice a palette change on its own. After any cycle/swap/fade/restore, call sprite.touch() on the sprites using that bitmap (or scene.invalidate() / repaint the tilemap region) or nothing visibly changes. The cost is a repaint of the affected sprites that frame, so cycle a small band (a strip of water), not the whole screen. See /memory/ for why this beats holding a second recoloured bitmap.