Grid-Based Movement in Unity 6: Tile-by-Tile Player Movement With Collision Checks
Build tile-by-tile grid movement in Unity 6 with Tilemap collision checks, smooth stepping between tiles, and held-key repeat. Full commented C# script included.
Imagine how much better things would be if we all walked on a grid. Everyone moves in predictable steps, and we finally lose that sidewalk fake-out where you go left, they go right, you both apologize, and then you both commit hard to the wrong direction anyway. What if you land on the same tile as someone, you ask? you battle them for their loot, obviously.
No? Fine, I guess freedom and unpredictability are important out here in the real world. But inside a game, grid-based movement is exactly what gives those classic JRPGs and roguelikes their feel: you snap from one tile to the next, walls are absolute, and nothing drifts into a corner at a weird angle. This tutorial builds that from scratch in Unity 6, with the wall check happening before the move commits, so you never get that one frame of clipping into a wall before bouncing back
What you’ll end up with
A player object that moves exactly one tile per key press, checks a wall layer before it moves (not after), animates the step over a fraction of a second instead of teleporting, and repeats automatically if you hold a direction down instead of making you tap the key over and over to cross a room. It’s the movement style you’d want for something like a traditional pixel-art JRPG, not a platformer or a free-roam action game.
Versions and prerequisites
This targets Unity 6 (6000.0.x or later), in a 2D project. It doesn’t touch rendering, so URP or the built-in pipeline both work fine.
You’ll need:
- The Input System package installed through Package Manager, with Active Input Handling set to “Input System Package (New)” or “Both” under Project Settings > Player. If the package is installed but the handling is still on the old Input Manager,
Keyboard.currentcomes back null and nothing happens. - A tilemap to walk on. Step 1 covers creating one if you don’t have it yet.
- Basic comfort reading C# and dragging references into the Inspector.
If you haven’t decided on a version control setup for this project yet, it’s worth sorting out before you start pasting scripts around. There’s a comparison of Git with LFS, Unity VCS, Diversion and Perforce for solo devs that covers the tradeoffs.
Step 1: set up the Grid and Tilemap so one tile equals one unit
Create the tilemap through GameObject > 2D Object > Tilemap > Rectangular. That gives you a Grid parent with a Tilemap child. Leave the Grid’s Cell Size X and Y at 1. That one setting is the whole trick to this tutorial: if one tile equals one world unit, every cell index is just a whole number, and moving “one tile right” is adding 1 to the x coordinate.
One thing that trips people up here, and it’s not obvious from looking at the Scene view: a tile’s origin sits at the integer coordinate, not its center. The tile at cell (0, 0) covers world space from (0, 0) to (1, 1), so its center is (0.5, 0.5). If you place a center-pivoted sprite at a whole number, it’ll straddle the point where four tiles meet instead of filling one. The script below handles this with GetCellCenterWorld, which is worth knowing about before you write your own version and wonder why everything looks half a tile off.
For the sprite itself, match Pixels Per Unit to your tile’s pixel size. A 16x16 pixel tile needs Pixels Per Unit set to 16 in the sprite’s import settings, so it renders as exactly one unit. Get this mismatched and your tiles will either overlap or leave gaps, and it’s not obvious from a glance in the Scene view which number is wrong.
If you’re making a placeholder tile, Aseprite works well for this and it’s what I use for test sprites in my own project. Grab a palette from Lospec if you’d rather not pick colors that clash. There’s a full comparison of pixel art tools if you’re still deciding what to use.
Add a second Tilemap and name it something like “Walls.” Paint it wherever the player shouldn’t be able to walk. You don’t need a Tilemap Collider 2D on it. The script asks the tilemap directly whether a tile exists, which avoids physics entirely. A grid doesn’t need a collision system that heavy.
Step 2: read movement input as a single direction
The usual way to read movement, a Vector2 from a composite input or Input.GetAxis, gives you diagonal values like (0.7, 0.7) when two keys are pressed at once. That’s correct for free movement and wrong here: a grid step needs to be exactly up, down, left, or right, never a blend of two.
The fix is to read the keyboard directly and pick one direction using a fixed priority order, so pressing two keys at once just picks one instead of producing a diagonal:
private Vector2Int ReadRawDirection()
{
var keyboard = Keyboard.current;
if (keyboard == null) return Vector2Int.zero;
if (keyboard.wKey.isPressed || keyboard.upArrowKey.isPressed) return Vector2Int.up;
if (keyboard.sKey.isPressed || keyboard.downArrowKey.isPressed) return Vector2Int.down;
if (keyboard.aKey.isPressed || keyboard.leftArrowKey.isPressed) return Vector2Int.left;
if (keyboard.dKey.isPressed || keyboard.rightArrowKey.isPressed) return Vector2Int.right;
return Vector2Int.zero;
}
My first attempt used Input.GetAxisRaw for both axes and just took whichever had a non-zero value, and it let you cheese diagonal movement past a wall corner that was only supposed to block straight-on approaches. Reading discrete keys with a priority order instead of two axes fixed it in a few minutes.
Step 3: check the target tile before you move
Once currentCell holds an actual tilemap cell index rather than a world position, the check is a single call:
private bool IsBlocked(Vector2Int cell)
{
return collisionTilemap.HasTile(new Vector3Int(cell.x, cell.y, 0));
}
The reason this stays this simple is that the conversion between world space and cell space happens exactly once, in Start(), using WorldToCell. That matters if your Walls tilemap’s transform isn’t sitting at the world origin: converting by hand, or assuming world coordinates are cell coordinates, gives you walls that block the wrong square by however much the tilemap is offset. Doing the conversion once at startup and then working purely in cell indices means the offset can’t come back to bite you on every subsequent check.
Checking before you move, rather than moving and then checking, is what avoids the one-frame clip into a wall that then snaps back. It looks janky even at 60 fps, and it’s easy to miss until you actually walk into a wall and see it happen.
Step 4: the GridMover script, complete and commented
Attach this to your player object. Drag the Walls tilemap into the collisionTilemap field in the Inspector.
using System.Collections;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.Tilemaps;
public class GridMover : MonoBehaviour
{
[Header("Movement")]
[SerializeField] private float moveDuration = 0.12f;
[SerializeField] private float inputRepeatDelay = 0.35f;
[SerializeField] private float inputRepeatRate = 0.12f;
[Header("Collision")]
[SerializeField] private Tilemap collisionTilemap;
// A tilemap cell index, not a world position. Everything below works in
// cell space and converts to world space only when it's time to move.
private Vector2Int currentCell;
private Vector2Int heldDirection;
private float repeatTimer;
private bool firstRepeatDone;
private bool isMoving;
private void Start()
{
if (collisionTilemap == null)
{
Debug.LogError("GridMover: assign the Walls tilemap in the Inspector.", this);
enabled = false;
return;
}
// Convert wherever you dropped the object in the editor into a cell,
// then place it on that cell's center. Integer world positions sit
// where four tiles meet, so a center-pivoted sprite left at a whole
// number straddles four tiles instead of filling one.
Vector3Int startCell = collisionTilemap.WorldToCell(transform.position);
currentCell = new Vector2Int(startCell.x, startCell.y);
transform.position = CellCenter(currentCell);
}
private void Update()
{
Vector2Int direction = ReadRawDirection();
if (direction == Vector2Int.zero)
{
heldDirection = Vector2Int.zero;
repeatTimer = 0f;
firstRepeatDone = false;
return;
}
if (direction != heldDirection)
{
// Fresh key press, or a direction change, moves immediately
heldDirection = direction;
repeatTimer = 0f;
firstRepeatDone = false;
if (!isMoving) TryMove(direction);
return;
}
// The timer keeps running while a step is animating. Pausing it there
// would add moveDuration to every repeat, so a held key would walk at
// roughly half the rate the Inspector values imply.
repeatTimer += Time.deltaTime;
float threshold = firstRepeatDone ? inputRepeatRate : inputRepeatDelay;
if (repeatTimer >= threshold && !isMoving)
{
repeatTimer = 0f;
firstRepeatDone = true;
TryMove(direction);
}
}
private Vector2Int ReadRawDirection()
{
var keyboard = Keyboard.current;
if (keyboard == null) return Vector2Int.zero;
if (keyboard.wKey.isPressed || keyboard.upArrowKey.isPressed) return Vector2Int.up;
if (keyboard.sKey.isPressed || keyboard.downArrowKey.isPressed) return Vector2Int.down;
if (keyboard.aKey.isPressed || keyboard.leftArrowKey.isPressed) return Vector2Int.left;
if (keyboard.dKey.isPressed || keyboard.rightArrowKey.isPressed) return Vector2Int.right;
return Vector2Int.zero;
}
private void TryMove(Vector2Int direction)
{
Vector2Int targetCell = currentCell + direction;
if (IsBlocked(targetCell)) return;
currentCell = targetCell;
StartCoroutine(MoveToCell(currentCell));
}
private bool IsBlocked(Vector2Int cell)
{
return collisionTilemap.HasTile(new Vector3Int(cell.x, cell.y, 0));
}
// Cell index to the world position at the middle of that tile. Using the
// tilemap for this means an offset or repositioned Grid still lines up.
private Vector3 CellCenter(Vector2Int cell)
{
Vector3 world = collisionTilemap.GetCellCenterWorld(new Vector3Int(cell.x, cell.y, 0));
return new Vector3(world.x, world.y, transform.position.z);
}
private IEnumerator MoveToCell(Vector2Int cell)
{
isMoving = true;
Vector3 start = transform.position;
Vector3 end = CellCenter(cell);
float elapsed = 0f;
while (elapsed < moveDuration)
{
elapsed += Time.deltaTime;
transform.position = Vector3.Lerp(start, end, elapsed / moveDuration);
yield return null;
}
transform.position = end; // snap exactly, no float drift left over
isMoving = false;
}
}
The isMoving guard is doing more work than it appears to. Without it, a held key firing on the same frame the coroutine finishes can start a second move before the first one has fully snapped into place, and you get stutter that’s hard to spot until you slow down and watch a single step.
Step 5: smooth the step between tiles without breaking the grid
The MoveToCell coroutine above is the whole smoothing job: it lerps the transform’s position from the old cell’s center to the new one over moveDuration seconds, then snaps to the exact target position when it’s done. That final snap is easy to skip and worth keeping. Vector3.Lerp over several frames of variable frame time will very rarely land on the exact target float value, and if you don’t force it there, you’ll get sprites resting a fraction of a pixel off-grid, which shows up as a faint shimmer or seam on tile edges. If you haven’t read up on why frame-rate-dependent timing causes this kind of drift in the first place, the piece on delta time covers the mechanics.
There’s no Rigidbody2D anywhere in this script on purpose. Grid movement doesn’t need physics simulation, and adding one just gives you another system that can nudge your position off the grid when you’re not looking.
Step 6: held-key repeat so walking across a room isn’t ten separate taps
This is the inputRepeatDelay and inputRepeatRate pair in the script. The pattern is the same one your OS uses for a held keyboard key: the first press moves immediately, then there’s a longer pause (inputRepeatDelay, 0.35 seconds above) before it starts auto-repeating, and after that it repeats at a faster, steady rate (inputRepeatRate, 0.12 seconds above).
One interaction worth understanding: a step can’t begin while the previous one is still animating, so your actual walking speed is whichever of moveDuration and inputRepeatRate is slower. With both at 0.12 they cancel out and you get continuous walking, one tile finishing as the next begins. Set inputRepeatRate well below moveDuration and nothing changes, because the animation is the bottleneck.
Those two numbers, plus moveDuration, are what actually decide how the movement feels. A shorter moveDuration with a fast repeat rate feels snappy and arcade-y. A longer moveDuration closer to the repeat rate feels more deliberate, closer to a classic turn-based RPG where each step has weight. Worth changing the numbers in the Inspector and walking around for a bit rather than guessing.
Common pitfalls
Sprites landing on half pixels. Almost always a mismatch between the sprite’s Pixels Per Unit and the Grid’s Cell Size. The corner-versus-center problem from Step 1 looks similar but is a different cause, and Start() handles that one by routing the starting position through WorldToCell and back out through GetCellCenterWorld.
NullReferenceException on the Tilemap reference. Usually means the collisionTilemap field was never dragged into the Inspector, or you dragged the Grid object in instead of the Tilemap child. The script above logs an error and disables itself rather than throwing every frame, but it’s still an easy thing to mix up when you’re moving fast, since the Grid and the Tilemap are different components on different objects.
Movement that keeps queueing after you let go. This one usually comes from reading input with GetKeyDown and stashing it in a queue to process later, rather than checking live keyboard state each frame like the script above does. If a press gets queued and then the key is released before the queue processes it, you get a phantom move after your hand is already off the key. Checking Keyboard.current directly in Update, with no queue in between, avoids that entirely.
How I’d extend it
Push blocks. Add a Pushable component to block objects. In TryMove, before checking the Walls tilemap, check for a Pushable at the target cell. If one’s there, try to move it one cell further in the same direction first, and only let the player move if that push succeeds.
Ledge hops. A one-way tile that only blocks movement from certain directions, the classic “jump down but can’t climb back up” ledge. You’d need a way to tag which direction a tile allows, either a custom Tile subclass with a direction field or a lookup dictionary keyed by cell position.
Encounter triggers on tile entry. Tag certain cells as encounter zones and fire an event when currentCell lands on one, then roll for a random battle. This is the kind of thing that would hook straight into a turn-based combat system if you’re building something in that direction, which is roughly what I’m working through for my own JRPG project (there’s a devlog on the combat system risks if you want the messier version of that story).
What this setup won’t do
It won’t give you diagonal movement, and that’s intentional, not a limitation to fix. It also won’t handle multiple NPCs occupying the same grid cleanly on its own. You’d need to track which cells are currently occupied, probably in a shared dictionary, so two characters can’t step onto the same tile at once.
It’s also not a pathfinding grid. If you want an NPC that walks a route around obstacles or chases the player across an open map, this script only answers “can I take one step in this direction right now.” That’s a different problem from “what’s the shortest route from A to B,” which needs an actual pathfinding algorithm like A* built on top of the same grid data. Worth knowing that difference going in, so you’re not trying to tack on pathfinding logic onto a script that was only built to answer the smaller question.
Registration card
Register to receive one focused post every weekday — engine tutorials, marketing tactics, and design craft. Free, unsubscribe anytime.
No spam. We never share your address. No purchase necessary — it's free.