gale.stencil

Pygame has no GPU stencil buffer, so gale.stencil gives you the same end result love2d’s love.graphics.stencil gets on the CPU: draw an arbitrary shape (a circle, a polygon, another sprite, text — anything you can draw or blit) into a Stencil’s own mask surface, then apply it to any other surface to zero out the alpha of every pixel outside that shape (or, with invert=True, every pixel inside it).

That is the classic building block for a top-down game: a circular torch/vision reveal over a dark fog-of-war overlay, a minimap cropped to a circle, a doorway that only shows what’s behind it once you’re close enough.

See examples/lantern for a full game built on this: a top-down exploration game where the room is covered in darkness except for a circle of light around the player.

Fog of war / vision reveal

import pygame
from gale.stencil import Stencil

stencil = Stencil((width, height))

# In render(), every frame the light's position/radius might change:
stencil.clear()
stencil.draw(lambda mask: pygame.draw.circle(mask, "white", light_pos, radius))

overlay = pygame.Surface((width, height), pygame.SRCALPHA)
overlay.fill((0, 0, 0, 230))
stencil.apply(overlay, invert=True)  # punch the circle out of the dark overlay
surface.blit(overlay, (0, 0))

Cropping to a shape

Without invert, apply keeps only what’s inside the drawn shape — handy for cropping a minimap (or any other surface) to a circle instead of a rectangle:

minimap = pygame.Surface((96, 96), pygame.SRCALPHA)
minimap.blit(world_snapshot, (0, 0))

crop = Stencil((96, 96))
crop.draw(lambda mask: pygame.draw.circle(mask, "white", (48, 48), 48))
crop.apply(minimap)  # everything outside the circle is now fully transparent

surface.blit(minimap, (8, 8))

How it works

draw() hands you the Stencil’s own surface to draw the “visible” shape onto, in opaque white — that’s the convention this module relies on (any alpha in your draw calls is fine; the RGB channels should stay white). apply() then multiplies the target surface’s own RGBA by the mask’s (pygame.BLEND_RGBA_MULT): inside the shape, that leaves the target’s color and alpha untouched (channel * 255 / 255); outside it, it zeroes both out (channel * 0). invert=True just applies the same multiplication against 255 - mask instead.

For this to have any visible effect once the masked surface is blit somewhere else, it must have per-pixel alpha — create it with pygame.SRCALPHA (or call .convert_alpha()), the same requirement any other alpha-blended surface has.

A single Stencil can be apply’d to as many surfaces as you like once its shape is drawn; call clear() before draw() again to reuse it for a new shape (e.g. next frame, once the light moves).