python 3.x – Tile based lighting system 2d

For 2D games like you’re making, how we could apply lighting – more like, shadowing – could go into 2 options:

  1. Change screen color to shadow color & set transparency to objects, as OP suggested
  2. Sandwich entire thing between screen and light layer

Let’s start with problem of 1st option:

Problem of setting transparency

Here’s demo code based on your idea:

Demonstration of color overlapping

import pygame as pg

class Player(pg.sprite.Sprite):
    def __init__(self):
        super(Player, self).__init__()
        self.image = pg.Surface((50, 50))
        self.image.fill((255, 255, 255))
        self.rect = self.image.get_rect()

        # setting alpha on player

    def update(self, *args, **kwargs):
        x, y = pg.mouse.get_pos()
        c_x, c_y =
        self.rect.move_ip(x - c_x, y - c_y)

def mainloop():
    player = Player()
    screen = pg.display.set_mode((500, 500))

    circle_colors = (255, 0, 0), (0, 255, 0), (0, 0, 255)
    circle_coords = (150, 250), (250, 250), (350, 250)

    # make surface, set alpha then draw circle
    bg_surfaces = []
    for (color, center) in zip(circle_colors, circle_coords):
        surface = pg.Surface((500, 500), pg.SRCALPHA, 32)
        surface.set_alpha(125), color, center, 75)

    running = True

    while running:
        screen.fill((0, 0, 0))

        # draw background
        for surface in bg_surfaces:
            screen.blit(surface, surface.get_rect())

        for event in pg.event.get():
            if event.type == pg.QUIT:
                running = False

        screen.blit(player.image, player.rect)

if __name__ == '__main__':

As you see, now the player (White square)’s color is Mixed with background circles.

It’s basically just like what the drawing program does with layers. Set layer transparency 50% and stack – everything mixes, producing an undesirable effect which is far from the lighting effect you wanted.

CSP demo

Unless you want Creeper or Steve to blend with the background and become a ghosty figure, it’s better to go for sandwiched layout.

Sandwiched Layout

Following is demo code which uses mouse position as light source position.

Rendering order is Ground > Player > light overlay(shadow)

Demo code:

Code demonstration for
Written on Python 3.10 (Using Match on input / event dispatching)

import math
import random
import itertools
from typing import Dict, Tuple, Sequence

import pygame as pg

class Position:
    """Namespace for size and positions"""
    tile_x = 20
    tile_size = tile_x, tile_x

class SpriteGroup:
    """Namespace for sprite groups, with chain iterator keeping the order"""
    ground = pg.sprite.Group()
    entities = pg.sprite.Group()
    light_overlay = pg.sprite.Group()

    def all_sprites(cls):
        return itertools.chain(cls.ground, cls.entities, cls.light_overlay)

class Player(pg.sprite.Sprite):
    """Player class, which is merely a rect following pointer in this example."""
    def __init__(self):
        super(Player, self).__init__()
        self.image = pg.Surface((50, 50))
        self.image.fill((255, 255, 255))
        self.rect = self.image.get_rect()


        self.rect.move_ip(225, 225)

    def update(self, *args, **kwargs):
        # Intentionally disabling mouse following code
        # x, y = pg.mouse.get_pos()
        # c_x, c_y =
        # self.rect.move_ip(x - c_x, y - c_y)

class TileLightOverlay(pg.sprite.Sprite):
    Light overlay tile. Using separate sprites, so we don't have to blit on
    every object above ground that requires lighting.

    # light lowest boundary
    lighting_lo = 255

    # light effect radius
    light_radius = Position.tile_x * 8

    def __init__(self, x, y):
        super(TileLightOverlay, self).__init__()

        self.image = pg.Surface(Position.tile_size)
        self.image.fill((0, 0, 0))

        self.rect = self.image.get_rect()
        self.rect.move_ip(x * Position.tile_x, y * Position.tile_x)


    def update(self, *args, **kwargs):

    def brightness(self):
        """Calculate distance between mouse & apply light falloff accordingly"""
        distance = math.dist(, pg.mouse.get_pos())

        if distance > self.light_radius:
            return self.lighting_lo

        return (distance / self.light_radius) * self.lighting_lo

class TileGround(pg.sprite.Sprite):
    """Ground tile representation. Not much is going on here."""

    def __init__(self, x, y, tile_color: Sequence[float]):
        super(TileGround, self).__init__()

        self.image = pg.Surface(Position.tile_size)

        self.rect = self.image.get_rect()
        self.rect.move_ip(x * Position.tile_x, y * Position.tile_x)


        # create and keep its pair light overlay tile.
        self.light_tile = TileLightOverlay(x, y)

class World:
    """World storing ground tile data."""
    # tile type storing color etc. for this example only have color.
    tile_type: Dict[int, Tuple[float, float, float]] = {
        0: (56, 135, 93),
        1: (36, 135, 38),
        2: (135, 128, 56)

    def __init__(self):
        # coord system : +x → / +y ↓
        # generating random tile data
        self.tile_data = [
            [random.randint(0, 2) for _ in range(25)]
            for _ in range(25)
        # generated tiles
        self.tiles = []

    def generate(self):
        """Generate world tiles"""
        for x, row in enumerate(self.tile_data):
            tiles_row = [TileGround(x, y, self.tile_type[col]) for y, col in enumerate(row)]

def process_input(event: pg.event.Event):
    """Process input, in case you need it"""
    match event.key:
        case pg.K_ESCAPE:
        case pg.K_UP:
        # etc..

def display_fps_closure(screen: pg.Surface, clock: pg.time.Clock):
    """FPS display"""
    font_name = pg.font.get_default_font()
    font = pg.font.Font(font_name, 10)
    color = (0, 255, 0)

    def inner():
        text = font.render(f"{int(clock.get_fps())} fps", True, color)
        screen.blit(text, text.get_rect())

    return inner

def mainloop():
    # keeping reference of method/functions to reduce access overhead
    fetch_events = pg.event.get
    display = pg.display

    # local variable setup
    screen = display.set_mode((500, 500))
    player = Player()
    world = World()

    clock = pg.time.Clock()
    display_fps = display_fps_closure(screen, clock)

    running = True

    # main loop
    while running:
        screen.fill((0, 0, 0))

        # process event
        for event in fetch_events():
            # event dispatch
            match event.type:
                case pg.QUIT:
                    running = False
                case pg.KEYDOWN:

        # draw in ground > entities > light overlay order
        for sprite in SpriteGroup.all_sprites():
            screen.blit(sprite.image, sprite.rect)

        # draw fps - not related to question, was lazy to remove & looks fancy


if __name__ == '__main__':

Demo image

You’ll see it’s blending properly with shadow without mixing color with ground tiles.

There could be much better approach or ways to implement this – as I never used pygame before, there would be a bunch of good/better stuffs I didn’t read on the document.

But one thing for sure – always approach your goal with mindset that everything is related to your problem until you reach the goal! Comment you thought it wasn’t going to be helpful gave me idea for this design.

Leave a Comment