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.
picogame_fx
Section titled “picogame_fx”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
fx.Shake - trauma-model screen shake
Section titled “fx.Shake - trauma-model screen shake”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_offsetis the peak pixel offset (about 6 suits 320x240; over 10 hides the action).decayis 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 callsscene.set_viewwith the sum. ReturnsTruewhile still shaking. Pass your camera offset here so shake and a moving camera don’t both callset_viewand 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=0is black;cellis the dither block size. Added to the scenefixed=Trueso it ignores the camera..set(level)- jump instantly tolevel(0 = clear .. 16 = solid). Use 16 to start opaque before a fade-in..to(target, speed=2.0)- head towardtargetatspeedlevels per frame. Returnsself..out(speed=2.0)/.into(speed=2.0)- shortcuts forto(16)(to opaque) andto(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 tolevelthen automatically back to 0; the smooth full-screen flash. Keeplevelunder 16 so it stays a see-through dither, never a solid wall..tick()- stepleveltowardtargetbyspeed. ReturnsTruewhen the target is reached..is_done(property) -Truewhenlevel == 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.
fx.Tween - ease a scalar toward a target
Section titled “fx.Tween - ease a scalar toward a 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)-speedis the fraction of the remaining gap closed each frame (0..1)..to(target, speed=None)- set a new target (and optionally a new speed). Returnsself..set(value)- snap value and target tovalueimmediately..tick()- move value aspeedfraction toward target and return the new value. Snaps exactly when within 0.01..is_done(property) -Trueonce 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.
fx.Camera - smoothed follow camera
Section titled “fx.Camera - smoothed follow camera”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/hare the screen size;lerpis 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)bylerp, or jump there withsnap=True. Returnsself, so you can chain..apply()- compute the offset and callscene.set_viewdirectly. Allocation-free; returnsNone. Use when there is no shake..offset()- compute and return the offset as an(ox, oy)tuple (allocates). Feed this intoShake.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.
fx.Sky - vertical gradient background
Section titled “fx.Sky - vertical gradient background”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 thetopwire-RGB565 colour tobottom. Addedfixed=True. Change.top/.bottomover time for a day-night cycle.
import picogame as pgimport 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.
fx.Scanlines - CRT scanline overlay
Section titled “fx.Scanlines - CRT scanline overlay”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=2darkens every other line;darkis 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 everythingGotchas: order matters - add it after every gameplay layer or it gets painted over.
fx.InvertFlash - free hardware hit-flash
Section titled “fx.InvertFlash - free hardware hit-flash”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)-displayis e.g.board.DISPLAY;framesis the flash length.normalis the panel’s resting invert state. The PicoPad sends INVON in its init, so its resting state isnormal=True(the default); passnormal=Falseonly 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. ReturnsTruewhile flashing. Call it afterscene.refresh()so the INVON/INVOFF is the frame’s last bus op.
import boardimport 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.
picogame_palette
Section titled “picogame_palette”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 anarray('H')copy of a palette. Save the original once, before any fading or cycling, so you can fade relative to it orrestoreit.restore(palette, base)- copybaseback intopalettein place.cycle(palette, lo, hi, step=1)- rotate entries[lo..hi]inclusive bystep(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 ofpalettefrom the savedbasetoward thetargetwire colour byt(0.0 = base .. 1.0 = target).target=0(black) fades out; a white target fades to white.skipleaves 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 changedGotchas: 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.