Use this guide when
Use this guide only after the current movement call returns OK and later-tick observations show no position progress. A non-OK return belongs in the return-code branch first; a Creep already inside the requested range may correctly stay on the same tile.
function countUnchangedTicks(previous, currentKey) {
const consecutive = previous
&& previous.tick === Game.time - 1
&& previous.position === currentKey;
return consecutive
? (previous.unchangedTicks ?? 0) + 1
: 0;
}
unchangedTicks is evidence of repeated position stability, not proof of a traffic cause. Combine it with the final movement intent, requested range, fatigue, and nearby occupancy before changing path policy.
Quick answer
moveTo() === OK means the movement request was accepted by that method call. It is not a promise that the Creep will occupy a different tile on the next tick. To debug an apparent stall, first prove that the call really returned OK, then compare the Creep position on later ticks and inspect the last movement call issued for that Creep in the current tick.
First confirm this is an OK-but-no-progress case
This page is deliberately narrower than a general movement guide. Do not use it when the current call already tells you why movement was rejected.
| Current result | Next diagnostic |
|---|---|
ERR_NO_PATH | Path search failed. Use the ERR_NO_PATH guide. |
ERR_TIRED | Inspect fatigue and body movement ratio. |
ERR_BUSY | Check the Creep state, including whether it is still spawning. |
ERR_NO_BODYPART | No active MOVE part remains. |
ERR_INVALID_TARGET | Fix the target before investigating traffic. |
OK | Now compare later-tick position progress and final movement intent. |
Compare positions across ticks
Do not decide that a Creep is stuck from one line of code. Save a compact position key and compare it on a later tick:
function getPositionKey(pos) {
if (!pos) return null;
return pos.roomName + ':' + pos.x + ':' + pos.y;
}
function getTargetPosition(target) {
if (target instanceof RoomPosition) {
return target;
}
if (target?.pos instanceof RoomPosition) {
return target.pos;
}
return null;
}
function getTargetKey(target) {
const pos = getTargetPosition(target);
return pos ? getPositionKey(pos) : null;
}
function recordMovementObservation(
creep,
target,
desiredRange,
moveResult,
caller
) {
Memory.moveDebug ??= {};
const previous = Memory.moveDebug[creep.name] ?? null;
const position = getPositionKey(creep.pos);
const targetKey = getTargetKey(target);
const sameSeries = Boolean(
previous
&& previous.tick === Game.time - 1
&& previous.targetKey === targetKey
&& previous.desiredRange === desiredRange
&& previous.moveResult === OK
);
const previousAcceptedWithoutProgress = Boolean(
sameSeries
&& previous.position === position
);
const consecutiveAcceptedStalls =
previousAcceptedWithoutProgress
? (previous.consecutiveAcceptedStalls ?? 0) + 1
: 0;
Memory.moveDebug[creep.name] = {
tick: Game.time,
position,
targetKey,
desiredRange,
moveResult,
caller,
consecutiveAcceptedStalls
};
return {
previousAcceptedWithoutProgress,
consecutiveAcceptedStalls,
previousTick: previous?.tick ?? null,
previousPosition: previous?.position ?? null,
currentPosition: position
};
}
The counter advances only when the previous observation is from the immediately preceding tick, targets the same position with the same requested range, and records moveTo() === OK. That makes it evidence for a repeated accepted-movement series instead of accidentally comparing unrelated observations several ticks apart. It still does not prove traffic, a stale cached path, or another cause; combine it with the final same-tick movement caller and local occupancy before changing policy.
Check whether movement is still needed
If you call moveTo(target, { range: 3 }) and the Creep is already within range 3, a lack of tile movement can be the correct outcome. Compare the current range with the range your role actually requires:
const desiredRange = 3;
const currentRange = creep.pos.getRangeTo(target);
if (currentRange <= desiredRange) {
// Do the range-3 action instead of demanding another tile step.
}
This matters for builders, repairers, and upgraders. “Position unchanged” and “movement failure” are not the same observation.
Find the last movement call in the tick
Screeps resolves simultaneous movement actions by giving the later movement method call priority for the same Creep. An early moveTo() can therefore return OK even though a later module submits a different movement intent.
function submitMove(creep, target, reason) {
const result = creep.moveTo(target, {
reusePath: 5
});
creep.memory.lastMoveDecision = {
tick: Game.time,
reason,
targetId: target.id ?? null,
result
};
return result;
}
In production, centralize movement or at least overwrite one diagnostic record every time a movement method is submitted. The final record should identify the last caller, not the first one that happened to log.
Check traffic and occupied tiles
Accepted movement still competes with other movement intents and occupied positions. A path can be valid while the immediate step is not obtained on that tick. The caller does not receive a separate post-resolution collision return code.
Use a later-tick position comparison together with a small local snapshot:
const nearbyCreeps = creep.pos.findInRange(
FIND_CREEPS,
1
).map(other => ({
name: other.name,
x: other.pos.x,
y: other.pos.y,
my: other.my
}));
This is diagnostic input, not proof that any particular nearby Creep caused the stall. Repeated observations are needed before changing traffic policy.
Review path options without blaming the cache
reusePath can reduce repeated path work, but an accepted call plus no movement is not automatically a “bad cache” diagnosis. First rule out range, final same-tick intent, traffic, and call failures. If the route itself becomes invalid, inspect the path search branch and current obstacles instead of setting reusePath: 0 everywhere.
For persistent path-search failure, continue with ERR_NO_PATH. For movement speed caused by fatigue and body design, use the fatigue/body-ratio guide.
Validate the target position
function hasValidRoomPosition(target) {
return Boolean(getTargetPosition(target));
}
The wrapper accepts either a real RoomPosition or a game object whose .pos is a RoomPosition. A plain deserialized {x, y, roomName} object is data, not a live RoomPosition; rebuild it with new RoomPosition(x, y, roomName) before using this helper. If your production caller uses the separate numeric x, y overload, normalize those coordinates to a RoomPosition for this diagnostic path.
Use one bounded diagnostic record
function debugMoveTo(creep, target, range = 1) {
if (!creep || !hasValidRoomPosition(target)) {
return { status: 'invalid-input' };
}
const before = getPositionKey(creep.pos);
const result = creep.moveTo(target, {
range,
reusePath: 5
});
const observation = recordMovementObservation(
creep,
target,
range,
result,
'debugMoveTo'
);
return {
status: result === OK ? 'accepted' : 'call-failed',
tick: Game.time,
before,
target: getTargetKey(target),
currentRange: creep.pos.getRangeTo(target),
requestedRange: range,
fatigue: creep.fatigue,
activeMove: creep.getActiveBodyparts(MOVE),
result,
previousPosition: observation.previousPosition,
previousTick: observation.previousTick,
previousAcceptedWithoutProgress:
observation.previousAcceptedWithoutProgress,
consecutiveAcceptedStalls:
observation.consecutiveAcceptedStalls
};
}
This record distinguishes current call failure from later-tick progress. It still does not prove why traffic resolution chose a particular outcome.
Production adaptation
A production movement manager should have one final owner for movement intent per Creep. Keep task selection separate from movement submission, record only bounded diagnostics, and define when a stationary Creep is expected because it already satisfies the role's range. Escalate to traffic/path replanning only after repeated later-tick evidence.
Checks in order before changing path policy
- Keep the current
moveTo()return code. Leave this guide immediately forERR_NO_PATH,ERR_TIRED,ERR_BUSY,ERR_INVALID_TARGET, orERR_NO_BODYPART. - Confirm the Creep is still outside the requested range. No tile change is required when the goal range is already satisfied.
- Compare a consecutive later-tick position for the same target and range.
- Identify the final movement call submitted for that Creep in the tick. A later movement call can replace the earlier intent.
- Only then inspect traffic, changing obstacles, and path reuse. One unchanged tick is diagnostic evidence, not a root-cause label.
This order prevents a common debugging loop where reusePath: 0 is applied globally before the code has proved that path reuse is the failing boundary.
When this guide does not apply
If the current result is not OK, follow that return code. If the original problem is an action returning ERR_NOT_IN_RANGE, use the action-range guide. This page does not claim a live traffic reproduction, a path-cache benchmark, or a measured number of stalled ticks.
Frequently asked questions
What does moveTo() returning OK prove?
It proves that this call accepted a movement request. Verify later-tick position progress separately.
Can a later movement call override it?
Yes. The later movement method submitted by the same Creep in the tick has priority.
Is ERR_TIRED part of an OK-but-not-moving case?
No. If the method returns ERR_TIRED, diagnose fatigue as a call failure instead.
What if the Creep has no MOVE part?
The current API includes ERR_NO_BODYPART (-12) for that state. It is not an accepted-OK stall.