Tile-based games
Raytracing
Game development
Lighting algorithms
Game design

Calculating which tiles are lit in a tile-based game raytracing

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

In a tile-based game, "which tiles are lit" is usually a line-of-sight problem, not full cinematic raytracing. You have a light source on a grid, a set of blocking tiles such as walls, and a radius or intensity rule that decides how far the light travels.

Model the Grid First

Before choosing an algorithm, define what a tile means in your game:

  • Does a wall tile block all light?
  • Can the wall tile itself still appear illuminated?
  • Is range measured with Euclidean distance, Manhattan distance, or a fixed number of steps?
  • Can light pass diagonally through corners?

These choices affect the output more than the specific implementation details. Many "lighting bugs" are actually rule-definition bugs.

A Simple and Reliable Approach

For a single light source, one of the easiest correct approaches is:

  1. Iterate over every tile inside the light radius.
  2. Trace a line from the source tile to the candidate tile.
  3. If the line hits a blocking tile before reaching the candidate, the tile is in shadow.
  4. Otherwise, mark it as lit.

On a square grid, Bresenham's line algorithm is a good fit because it visits the same integer grid cells a straight line would cross.

Python Example Using Bresenham Lines

This example marks tiles visible from a light source L. Walls are #, floors are ., and lit floor tiles are shown as *.

python
1def bresenham(x0, y0, x1, y1):
2    points = []
3    dx = abs(x1 - x0)
4    dy = -abs(y1 - y0)
5    sx = 1 if x0 < x1 else -1
6    sy = 1 if y0 < y1 else -1
7    err = dx + dy
8
9    while True:
10        points.append((x0, y0))
11        if x0 == x1 and y0 == y1:
12            return points
13        e2 = 2 * err
14        if e2 >= dy:
15            err += dy
16            x0 += sx
17        if e2 <= dx:
18            err += dx
19            y0 += sy
20
21
22def lit_tiles(grid, source_x, source_y, radius):
23    height = len(grid)
24    width = len(grid[0])
25    lit = set()
26
27    for y in range(height):
28        for x in range(width):
29            dx = x - source_x
30            dy = y - source_y
31            if dx * dx + dy * dy > radius * radius:
32                continue
33
34            blocked = False
35            for px, py in bresenham(source_x, source_y, x, y)[1:]:
36                if grid[py][px] == "#":
37                    if (px, py) != (x, y):
38                        blocked = True
39                    break
40
41            if not blocked:
42                lit.add((x, y))
43
44    return lit
45
46
47grid = [
48    "##########",
49    "#....#...#",
50    "#....#...#",
51    "#..L.....#",
52    "#....###.#",
53    "#........#",
54    "##########",
55]
56
57source_x, source_y = 3, 3
58lit = lit_tiles(grid, source_x, source_y, radius=5)
59
60for y, row in enumerate(grid):
61    rendered = []
62    for x, cell in enumerate(row):
63        if (x, y) == (source_x, source_y):
64            rendered.append("L")
65        elif cell == "#":
66            rendered.append("#")
67        elif (x, y) in lit:
68            rendered.append("*")
69        else:
70            rendered.append(".")
71    print("".join(rendered))

This is easy to understand and debug, which is why it is a good starting point for roguelikes and other grid-heavy games.

Performance Characteristics

The downside of per-tile line tracing is cost. If the radius is r, you inspect roughly O(r^2) candidate tiles, and each candidate may trace a line of length O(r). That gives a worst-case cost around O(r^3) per light.

For small maps or a handful of lights, that is usually fine. If you have many lights or large radii, consider a more specialized field-of-view algorithm such as recursive shadowcasting or permissive FOV. Those methods avoid tracing a separate line to every tile.

Intensity and Falloff

Visibility answers only whether a tile is reachable by light. If you also want brightness, add a falloff rule after visibility has been decided.

A common choice is to scale brightness by squared distance:

  • distance squared d2 = dx*dx + dy*dy
  • intensity = max(0, 1 - d2 / radius^2)

That keeps the shadow logic and the brightness logic separate, which makes the code easier to reason about.

Common Pitfalls

Corner handling is a common source of disagreement. If two walls meet diagonally, should light leak through the corner or not? Your algorithm must match the movement and visibility rules of the game.

Another mistake is casting rays only toward map edges and assuming the covered cells are enough. That can leave holes or asymmetries. Casting a line to each candidate tile is slower, but it is easier to make correct.

Developers also mix up "a wall blocks light" with "a wall is never lit." Many games light the wall tile that first receives the ray, then stop propagation beyond it.

Finally, if you have several light sources, combine their intensities carefully. Summing them without clamping can produce unrealistic brightness spikes.

Summary

  • In tile-based games, lit-tile calculation is usually a grid visibility problem.
  • A simple algorithm is to test each tile in range with a Bresenham line from the light source.
  • This approach is easy to debug and works well for small or medium scenes.
  • For many lights or large radii, recursive shadowcasting is usually faster.
  • Separate shadow blocking from brightness falloff to keep the implementation clear.

Course illustration
Course illustration

All Rights Reserved.