click detection
performance optimization
isometric grid
staggered column grid
algorithm improvement

Improving performance of click detection on a staggered column isometric grid

Master System Design with Codemia

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

Introduction

Slow click detection on an isometric staggered grid usually comes from solving the wrong problem. If your code loops through many tiles and checks hit polygons one by one, you are paying linear cost for something that can often be reduced to a small constant amount of arithmetic and at most a few local correction checks.

Treat Click Detection as a Coordinate Conversion Problem

A staggered-column isometric grid is not arbitrary geometry. Each tile follows a repeating pattern, which means screen coordinates can be converted into candidate grid coordinates directly.

The usual optimization path is:

  1. subtract camera or viewport offsets
  2. estimate the column from the x-coordinate
  3. compensate for that column’s vertical offset
  4. estimate the row from the adjusted y-coordinate
  5. correct for edge cases near the diamond corners

That is much faster than testing every tile on screen.

Start with a Fast Candidate Tile

Assume:

  • 'tileWidth is the width of the bounding box'
  • 'tileHeight is the height of the bounding box'
  • odd columns are shifted down by half a tile

You can compute a first guess in constant time:

typescript
1type Tile = { col: number; row: number };
2
3function candidateTile(
4  screenX: number,
5  screenY: number,
6  cameraX: number,
7  cameraY: number,
8  tileWidth: number,
9  tileHeight: number
10): Tile {
11  const localX = screenX - cameraX;
12  const localY = screenY - cameraY;
13
14  const col = Math.floor(localX / tileWidth);
15  const yOffset = col % 2 === 0 ? 0 : tileHeight / 2;
16  const row = Math.floor((localY - yOffset) / tileHeight);
17
18  return { col, row };
19}

This gives you the tile’s bounding-box candidate immediately. For many clicks, that answer is already correct.

Correct Only the Nearby Edge Cases

The expensive part of isometric picking is not the whole map, it is the small set of cases where the click lands in one of the triangular corner regions of the candidate box. Instead of testing the whole grid, test only the candidate tile and a few neighbors.

One practical approach is to convert the point into local tile-box coordinates and use a diamond test.

typescript
1function isInsideDiamond(
2  pointX: number,
3  pointY: number,
4  left: number,
5  top: number,
6  tileWidth: number,
7  tileHeight: number
8): boolean {
9  const halfW = tileWidth / 2;
10  const halfH = tileHeight / 2;
11  const centerX = left + halfW;
12  const centerY = top + halfH;
13
14  const dx = Math.abs(pointX - centerX) / halfW;
15  const dy = Math.abs(pointY - centerY) / halfH;
16
17  return dx + dy <= 1;
18}

If the click is not inside the candidate tile’s diamond, test only the small set of adjacent tiles that could own that corner. That turns a full-map search into a bounded local correction step.

Precompute Reused Values

If click detection runs every frame for hover previews or drag selection, remove avoidable arithmetic from the hot path.

For example:

typescript
const halfW = tileWidth / 2;
const halfH = tileHeight / 2;

Keep these values cached instead of recalculating them in every helper. The gain per call is small, but in a real-time interaction loop the savings are steady and free.

You should also cache:

  • camera transform values
  • map origin offsets
  • grid dimensions if bounds checking is frequent

A Practical O(1) Picking Function

Here is a compact version that combines candidate selection and local validation:

typescript
1function pickTile(
2  screenX: number,
3  screenY: number,
4  cameraX: number,
5  cameraY: number,
6  tileWidth: number,
7  tileHeight: number
8): Tile | null {
9  const localX = screenX - cameraX;
10  const localY = screenY - cameraY;
11
12  const col = Math.floor(localX / tileWidth);
13  const yOffset = col % 2 === 0 ? 0 : tileHeight / 2;
14  const row = Math.floor((localY - yOffset) / tileHeight);
15
16  const top = row * tileHeight + yOffset;
17  const left = col * tileWidth;
18
19  if (isInsideDiamond(localX, localY, left, top, tileWidth, tileHeight)) {
20    return { col, row };
21  }
22
23  const neighbors: Tile[] = [
24    { col: col - 1, row },
25    { col: col + 1, row },
26    { col, row: row - 1 },
27    { col, row: row + 1 }
28  ];
29
30  for (const neighbor of neighbors) {
31    const neighborOffset = neighbor.col % 2 === 0 ? 0 : tileHeight / 2;
32    const neighborLeft = neighbor.col * tileWidth;
33    const neighborTop = neighbor.row * tileHeight + neighborOffset;
34
35    if (isInsideDiamond(localX, localY, neighborLeft, neighborTop, tileWidth, tileHeight)) {
36      return neighbor;
37    }
38  }
39
40  return null;
41}

The important part is that the algorithm never scans all visible tiles.

When Spatial Indexing Helps

If your map also contains irregular clickable objects, a quadtree or chunked spatial index can help. For the base tile grid alone, a direct coordinate transform is usually simpler and faster.

Common Pitfalls

The biggest bug source is parity handling. Even and odd columns do not share the same vertical origin, so a correct row formula for one parity may be wrong for the other.

Another common mistake is forgetting camera transforms. If the map is scrolled or zoomed, convert screen coordinates into grid-local coordinates before running the picking math.

Negative coordinates also deserve attention. Math.floor behaves differently from integer truncation for negative numbers, which can break picking near the map origin.

Finally, avoid per-tile polygon hit-tests unless you truly need them. They are usually much slower than a candidate-plus-neighbor approach.

Summary

  • Do not scan the whole grid for every click.
  • Convert screen coordinates into a candidate tile in constant time.
  • Use a local diamond test to validate the candidate.
  • Correct edge cases by checking only nearby neighbors.
  • Cache half-dimensions and offsets if picking happens frequently.

Course illustration
Course illustration