Quick answer
Creep.pull() is not an automatic drag command. A working pull step is a coordinated movement contract between two adjacent Creeps. In the same tick, the puller must schedule its own movement, call pull(target), and the target must call move(puller).
Preserve the return value from every call. OK means the current-tick intent was accepted by that API boundary; it does not prove that both positions changed. Store the pair identity and starting positions if you need to verify the processed movement on a later tick.
Use the three-intent pattern
The official API example uses this ordering: move the puller, establish the pull, then make the target move toward the puller. This minimal fragment assumes you have already selected two adjacent Creeps and a valid direction:
const pullerMoveResult = puller.move(direction);
const pullResult = puller.pull(target);
const targetMoveResult = target.move(puller);
console.log(JSON.stringify({
pullResult,
pullerMoveResult,
targetMoveResult
}));
All three results matter. If the puller's move fails, there is no useful destination step. If pull() fails, there is no valid pull relationship. If target.move(puller) fails, the target did not submit the special movement intent that completes the documented pair pattern.
Why pull() alone does not move the target
pull(target) establishes the relationship used by movement processing. It does not choose where the puller should go, and it does not submit the target's movement intent for you. That is why code like puller.pull(target) by itself can return OK without becoming a complete movement instruction.
The target's call is also unusual. Creep.move() normally receives a direction from 1 through 8, but the API also accepts a nearby Creep object for pulling. In that special form, the official documentation says the target's ERR_TIRED and ERR_NO_BODYPART checks are bypassed.
const targetMoveResult = target.move(puller);
if (targetMoveResult !== OK) {
console.log(JSON.stringify({ targetMoveResult }));
}
That exception belongs to the pulled target's special move(puller) call. It does not make the puller's own directional movement immune to fatigue, missing MOVE parts, obstacles, or normal movement conflicts.
Check the pair before scheduling movement
A robust coordinator should reject impossible or ambiguous pair state before emitting any movement intent:
- Both objects exist and are Creeps you intend to coordinate. Do not keep stale names or IDs without resolving them again on the current tick.
- The pair is adjacent.
pull()returnsERR_NOT_IN_RANGEwhen the target is not next to the puller. - Neither Creep is still spawning. A spawning Creep cannot participate in this pair step.
- The target is not the puller itself. Self-pulling is not a valid target.
- The puller has an active
MOVEpart and no current fatigue. The puller must still submit a normal directional move. - Your coordinator controls both Creeps. The helper below requires
target.mybecause it must calltarget.move(puller). This is a coordinator policy, not a claim thatpull()itself documents an ownership error for the target.
If your problem is the puller's ordinary movement speed rather than pair coordination, use the movement-speed and fatigue guide instead of hiding that calculation inside the pull helper.
Read pull() return codes without inventing one
The current official Creep.pull() return table documents these outcomes:
| Return | Meaning for this call | Next check |
|---|---|---|
OK | The pull intent was accepted at the API boundary. | Check the two movement calls, then verify positions later if the result matters. |
ERR_NOT_OWNER | You do not own the puller. | Resolve the Creep identity and ownership before scheduling it. |
ERR_BUSY | The puller is still spawning. | Wait until the Creep exists as an active unit. |
ERR_INVALID_TARGET | The target is not a valid other Creep for this pull. | Check object type, spawning state, and self-targeting. |
ERR_NOT_IN_RANGE | The target is not adjacent. | Move the pair into adjacency before trying the pull step. |
The same documentation says pulling requires a MOVE body part, but its pull() return table does not list ERR_NO_BODYPART. The current public Screeps engine API layer likewise registers pull() after ownership, spawning, target, and adjacency checks without emitting that code.
MOVE part on the puller before scheduling the pair. Do not manufacture an undocumented pull() return branch to represent that precondition.Understand where pulling fatigue goes
Pulling does not make movement weight disappear. The official API says the fatigue generated for the target's movement is added to the puller instead of the target. That makes the puller's body, the target's body and carried resources, terrain, roads, and boosts relevant to sustained pull speed.
This guide deliberately does not promise a fixed travel rate for a pull pair. A useful speed statement needs the actual bodies, active parts, carried resources, terrain sequence, roads, boosts, and the pair's observed movement. If you only need the general fatigue math, keep that concern in the dedicated movement-speed guide.
Use a coordinator that preserves each failure stage
The helper below validates the state this pair coordinator depends on, then submits each call in the documented order. It returns the exact stage that failed rather than collapsing everything into a boolean:
function schedulePullStep(puller, target, direction) {
if (!puller || !target) {
return { ok: false, stage: 'validate', reason: 'missing-creep' };
}
if (!puller.my || !target.my) {
return { ok: false, stage: 'validate', reason: 'pair-not-owned' };
}
if (puller.spawning || target.spawning) {
return { ok: false, stage: 'validate', reason: 'spawning' };
}
if (puller.id === target.id) {
return { ok: false, stage: 'validate', reason: 'same-creep' };
}
if (!puller.pos.isNearTo(target.pos)) {
return { ok: false, stage: 'validate', reason: 'not-adjacent' };
}
if (puller.getActiveBodyparts(MOVE) === 0) {
return { ok: false, stage: 'validate', reason: 'puller-no-move' };
}
if (puller.fatigue > 0) {
return { ok: false, stage: 'validate', reason: 'puller-tired' };
}
if (!Number.isInteger(direction) || direction < 1 || direction > 8) {
return { ok: false, stage: 'validate', reason: 'invalid-direction' };
}
const pullerMoveResult = puller.move(direction);
if (pullerMoveResult !== OK) {
return { ok: false, stage: 'puller-move', code: pullerMoveResult };
}
const pullResult = puller.pull(target);
if (pullResult !== OK) {
return { ok: false, stage: 'pull', code: pullResult };
}
const targetMoveResult = target.move(puller);
if (targetMoveResult !== OK) {
return { ok: false, stage: 'target-move', code: targetMoveResult };
}
return {
ok: true,
stage: 'scheduled',
codes: {
pull: pullResult,
pullerMove: pullerMoveResult,
targetMove: targetMoveResult
}
};
}
Requiring target.my is intentional here: this function owns both movement calls. If your architecture does not control the target's code, it cannot guarantee the three-intent contract and should not report a fully scheduled pair.
Verify actual movement on a later tick
Current-tick return values are not later-tick position evidence. If the operation matters enough to verify, store the exact Creep IDs, starting positions, tick, and current-tick result. On a later tick, resolve those IDs again before comparing positions:
function rememberPullAttempt(puller, target, result) {
Memory.pullCheck = {
tick: Game.time,
pullerId: puller.id,
targetId: target.id,
pullerStart: {
x: puller.pos.x,
y: puller.pos.y,
roomName: puller.pos.roomName
},
targetStart: {
x: target.pos.x,
y: target.pos.y,
roomName: target.pos.roomName
},
result
};
}
function positionChanged(start, creep) {
return Boolean(creep) && (
creep.pos.x !== start.x ||
creep.pos.y !== start.y ||
creep.pos.roomName !== start.roomName
);
}
function inspectPreviousPull() {
const check = Memory.pullCheck;
if (!check || check.tick >= Game.time) return null;
const puller = Game.getObjectById(check.pullerId);
const target = Game.getObjectById(check.targetId);
return {
scheduledAt: check.tick,
pullerExists: Boolean(puller),
targetExists: Boolean(target),
pullerMoved: positionChanged(check.pullerStart, puller),
targetMoved: positionChanged(check.targetStart, target),
pairAdjacentNow: Boolean(
puller && target && puller.pos.isNearTo(target.pos)
)
};
}
pullerMoved and targetMoved tell you what changed; they do not prove that no other movement interaction affected the pair. If all calls returned OK but one or both positions did not change, continue with the movement progress diagnostic and inspect obstacles, competing movement, fatigue, and traffic rather than rewriting the pull return code.
Debug a pull pair in the right order
- Resolve both current-tick objects. Log IDs, names, positions, spawning state, and ownership before using saved state.
- Check adjacency. A range failure belongs before movement theory.
- Check the puller's movement capability. Inspect active
MOVEparts and current fatigue. - Log all three return values. Do not log only
pull()and then guess which movement call failed. - Keep the target's special move form exact. The target should call
move(puller), not copy the puller's direction as if the two calls were ordinary independent movement. - Inspect a later tick. Re-resolve both IDs and compare the positions you actually recorded.
- Only then investigate broader movement conflicts. Traffic, obstacles, competing intents, and route choice are downstream branches once the pair contract is known to have been scheduled correctly.
Keep pathfinding separate from pair movement
PathFinder.search() can choose the puller's next step, but it does not establish the pull relationship. Reject an incomplete search before converting its first complete path step into a direction:
function movePullPairToward(
puller,
target,
destination,
goalRange = 0
) {
const search = PathFinder.search(
puller.pos,
{ pos: destination, range: goalRange }
);
if (search.incomplete) {
return { ok: false, reason: 'incomplete-path' };
}
if (search.path.length === 0) {
return { ok: true, reason: 'goal-range-satisfied' };
}
const direction = puller.pos.getDirectionTo(search.path[0]);
return schedulePullStep(puller, target, direction);
}
Pass the goal range that belongs to the downstream task; do not hard-code range 1 for every destination. The PathFinder.search() guide owns goal ranges, incomplete, partial paths, search costs, and search limits. This page only owns the two-Creep movement handoff after a valid next step exists.
Know what this guide does not own
This article covers one adjacent puller-target pair and one coordinated movement step. It does not promise a convoy algorithm, formation movement, cross-room persistence, traffic arbitration, or a fixed travel speed. Those systems need additional state, routing, collision policy, and measurement.
It also does not claim Screeps Console or live-shard execution for the examples above. The API contract was checked against current official documentation, the return-code edge was checked against the current public engine source, and the coordinator examples passed static syntax review plus deterministic local cases. Runtime pair movement remains a separate evidence level.
FAQ
Does puller.pull(target) move the target automatically?
No. Coordinate the puller's movement, pull(target), and target.move(puller) in the same tick.
Can the pulled target have fatigue or no active MOVE part?
The official special move(puller) form bypasses the target's ERR_TIRED and ERR_NO_BODYPART checks. The puller still has to make a valid movement step.
Should I expect pull() to return ERR_NO_BODYPART?
Do not build that assumption into your code. It is not listed in the current official pull() return table. Validate the puller's active MOVE part before scheduling the pair.
Why store the pair in Memory?
You only need persistent evidence when you want to compare the accepted current-tick calls with positions observed on a later tick. Do not store extra state when the result is not important to verify.