Isometric Mouse

You know how to move in isometric. You know how to click-to-move. Now combine them. The tricky part isn’t the movement β€” it’s figuring out which diamond tile the mouse is actually over:

Loading editor…

THE CHALLENGE πŸ€”

In the previous mouse tutorial, converting a click to a tile was simple division:

const tileCol = Math.floor(mx / TILE_SIZE);
const tileRow = Math.floor(my / TILE_SIZE);

In isometric, tiles are placed at angles. The same screen pixel could be in completely different logical tiles depending on whether you’re looking at it as a row or column. Simple division doesn’t work anymore.

The diamond tile that lives at logical position (2, 1) appears at a completely different screen location than the tile at (1, 2) β€” even though both might be at the same screen Y coordinate. You need to invert the isoToScreen() transform.

INVERTING THE FORMULA

The forward transform is:

screenX = (worldX - worldY) + OFFSET_X
screenY = (worldX + worldY) / 2 + OFFSET_Y

To invert it, solve for worldX and worldY. First, strip the offsets:

relX = screenX - OFFSET_X  β†’  relX = worldX - worldY       ... (1)
relY = screenY - OFFSET_Y  β†’  relY = (worldX + worldY) / 2 ... (2)

Add equation (1) to 2 Γ— equation (2):

relX + 2 Γ— relY = (worldX - worldY) + (worldX + worldY) = 2 Γ— worldX
∴ worldX = (relX + 2 Γ— relY) / 2

Subtract equation (1) from 2 Γ— equation (2):

2 Γ— relY - relX = (worldX + worldY) - (worldX - worldY) = 2 Γ— worldY
∴ worldY = (2 Γ— relY - relX) / 2

In code:

function screenToWorld(mx, my) {
    const relX = mx - OFFSET_X;
    const relY = my - OFFSET_Y;
    return {
        worldX: (relX + 2 * relY) / 2,
        worldY: (2 * relY - relX) / 2
    };
}

Then round worldX / TILE_SIZE and worldY / TILE_SIZE to get tile coordinates:

canvas.addEventListener('mousemove', (e) => {
    const rect = canvas.getBoundingClientRect();
    const mx = e.clientX - rect.left;
    const my = e.clientY - rect.top;

    const world = screenToWorld(mx, my);
    mouseCol = Math.round(world.worldX / TILE_SIZE);
    mouseRow = Math.round(world.worldY / TILE_SIZE);
});

Math.round (not Math.floor) gives the nearest tile center, which feels right for isometric picking.

THE DIAMOND CURSOR πŸ”Ά

A rectangular highlight rectangle looks wrong on diamond tiles. Draw the cursor as a diamond polygon matching the tile shape exactly:

// Matches makeGroundTile() exactly - same polygon, just semi-transparent
const cursor = new PIXI.Graphics()
    .poly([30, 0, 60, 15, 30, 30, 0, 15])
    .fill({ color: 0xffff00, alpha: 0.4 });
cursor.zIndex = 9999; // always on top
app.stage.addChild(cursor);

canvas.addEventListener('mousemove', (e) => {
    // ... get mouseCol, mouseRow via screenToWorld() ...

    if (isWalkable(mouseCol, mouseRow)) {
        // Position using the same formula as regular tiles
        const screen = isoToScreen(mouseCol * TILE_SIZE, mouseRow * TILE_SIZE);
        cursor.x = screen.x - TILE_SIZE;
        cursor.y = screen.y - TILE_SIZE / 2;
        cursor.visible = true;
    } else {
        cursor.visible = false;
    }
});

The cursor polygon is identical to the ground tile polygon - it just snaps to the nearest valid tile as the mouse moves.

THE MOVEMENT CODE 🚢

The tile-by-tile movement from tutorial 20 works unchanged in world space. worldX % TILE_SIZE === CENTER still detects tile centers, dirX/dirY still control horizontal/vertical movement - it’s all the same. Only the final render step changes:

function movePlayer() {
    // ... same atCenter check, same direction picking as tutorial 20 ...

    player.worldX += player.dirX * player.speed;
    player.worldY += player.dirY * player.speed;

    // Convert world position to isometric screen coords
    const screen = isoToScreen(player.worldX, player.worldY);
    player.sprite.x = screen.x - 8;  // center 16px sprite
    player.sprite.y = screen.y - 4;  // center 8px sprite
    player.sprite.zIndex = player.worldX + player.worldY + TILE_SIZE / 2;
}

The player visually glides diagonally along the isometric grid while internally moving on the flat world grid.

What you’ve built:

  • βœ… screenToWorld() inverse transform derived from the forward formula
  • βœ… Diamond-shaped tile cursor that snaps to the nearest tile
  • βœ… Click-to-move that targets isometric tiles correctly
  • βœ… Tile-by-tile movement adapted to render in isometric space

Next up: The iso world gets bigger than the screen. Next: Isometric Scroll