Calculate the replacement deadline
Waiting until a Harvester, Hauler, or Upgrader disappears before calling spawnCreep() starts the replacement too late. A successor may wait for current Spawn work, consume time for every body part, and travel to its work position while the old Creep's ticksToLive keeps falling.
replacement lead = earliest Spawn wait
+ body spawn time
+ travel time
+ safety buffer
The base body time is body.length × CREEP_SPAWN_TIME. This guide keeps the documented base time as a conservative default instead of assuming PWR_OPERATE_SPAWN will remain available.
Travel is not linear range. Roads, swamps, MOVE-to-load ratio, traffic, exits, and a blocked work tile can all delay arrival. Begin with a conservative role value, then record the tick when spawning finishes and the tick when the Creep first satisfies its role-specific arrival condition.
Keep the search intent separate
The spawnCreep return-code guide diagnoses one failed request. The dynamic body guide decides which body is affordable. The emergency recovery guide starts after a critical role has already disappeared.
This route owns one narrower intent: begin normal replacement before the old Creep leaves its work position. Restoring the original prespawn slug avoids creating a second competing Spawn-queue page.
Prevent duplicate replacements
A role target of two can temporarily become three after the successor finishes while the low-TTL Creep remains alive. Treat that surplus as coverage:
uncovered expiring count
= active Creeps inside the lead threshold
- temporary surplus above the role target
Submit another request only when the uncovered value is positive. Count currently spawning Creeps separately so one pending successor is not requested again.
Use a testable decision function
function evaluateRoleReplacement(input) {
const {
targetCount,
activeTtls,
spawningCount,
spawnTicks,
spawnWaitTicks,
travelTicks,
safetyBuffer
} = input;
const integers = [
targetCount,
spawningCount,
spawnTicks,
spawnWaitTicks,
travelTicks,
safetyBuffer
];
if (
!integers.every(Number.isInteger)
|| targetCount < 1
|| spawningCount < 0
|| spawnTicks < 1
|| spawnWaitTicks < 0
|| travelTicks < 0
|| safetyBuffer < 0
|| !Array.isArray(activeTtls)
|| !activeTtls.every(ttl =>
Number.isInteger(ttl) && ttl >= 0
)
) {
return { valid: false, shouldSpawn: false, reason: 'invalid-input' };
}
const leadTicks = spawnWaitTicks + spawnTicks + travelTicks + safetyBuffer;
const totalCount = activeTtls.length + spawningCount;
const missingCount = Math.max(0, targetCount - totalCount);
const surplusCount = Math.max(0, totalCount - targetCount);
const dueCount = activeTtls.filter(ttl => ttl <= leadTicks).length;
const uncoveredDueCount = Math.max(0, dueCount - surplusCount);
const minimumSlack = activeTtls.length > 0
? Math.min(...activeTtls.map(ttl => ttl - leadTicks))
: Number.NEGATIVE_INFINITY;
if (missingCount > 0) {
return {
valid: true,
shouldSpawn: true,
reason: 'count-below-target',
leadTicks,
missingCount,
uncoveredDueCount,
minimumSlack
};
}
if (uncoveredDueCount > 0) {
return {
valid: true,
shouldSpawn: true,
reason: 'prespawn-due',
leadTicks,
missingCount,
uncoveredDueCount,
minimumSlack
};
}
return {
valid: true,
shouldSpawn: false,
reason: 'covered',
leadTicks,
missingCount,
uncoveredDueCount,
minimumSlack
};
}
minimumSlack is positive before the deadline, zero at the threshold, and negative after the ideal submission time. It is a local scheduling metric, not a Screeps return code.
Submit through one room manager
All role requests should converge on one room-level owner. The following bounded manager prevents repeated execution in the same tick, ranks actual shortages before normal replacements, performs dryRun, and preserves the formal result.
const HISTORY_LIMIT = 20;
const ROLE_CONFIG = {
harvester: {
priority: 10,
targetCount: 2,
body: [WORK, WORK, CARRY, MOVE],
travelTicks: 25,
safetyBuffer: 15
},
hauler: {
priority: 20,
targetCount: 2,
body: [CARRY, CARRY, MOVE],
travelTicks: 18,
safetyBuffer: 15
},
upgrader: {
priority: 30,
targetCount: 1,
body: [WORK, WORK, CARRY, CARRY, MOVE, MOVE],
travelTicks: 12,
safetyBuffer: 15
}
};
function getRoomState(roomName) {
Memory.prespawnReplacement ??= {};
Memory.prespawnReplacement[roomName] ??= {
lastRunTick: null,
pending: null,
history: []
};
return Memory.prespawnReplacement[roomName];
}
function verifyPendingReplacement(state) {
const pending = state.pending;
if (!pending || pending.tick >= Game.time) return null;
const creep = Game.creeps[pending.name] ?? null;
const spawn = Game.spawns[pending.spawnName] ?? null;
const observedInSpawn = spawn?.spawning?.name === pending.name;
const observedAsCreep = Boolean(creep);
const record = {
...pending,
verifiedAt: Game.time,
observedInSpawn,
observedAsCreep,
status: observedInSpawn || observedAsCreep
? 'replacement-observed'
: 'replacement-not-observed'
};
state.history ??= [];
state.history.push(record);
state.history = state.history.slice(-HISTORY_LIMIT);
state.lastVerification = record;
state.pending = null;
return record;
}
function getUsableSpawns(room) {
return room.find(FIND_MY_SPAWNS)
.filter(spawn => spawn.my === true && spawn.isActive())
.sort((left, right) => left.name.localeCompare(right.name));
}
function belongsToRoom(creep, roomName) {
const homeRoom = creep.memory?.homeRoom ?? creep.memory?.home;
return typeof homeRoom === 'string'
? homeRoom === roomName
: creep.room.name === roomName;
}
function createRoleRequest(room, role, config, spawnWaitTicks) {
const creeps = Object.values(Game.creeps).filter(creep =>
creep.memory?.role === role && belongsToRoom(creep, room.name)
);
const activeCreeps = creeps
.filter(creep => !creep.spawning && Number.isInteger(creep.ticksToLive))
.sort((left, right) =>
left.ticksToLive - right.ticksToLive
|| left.name.localeCompare(right.name)
);
const spawningCount = creeps.filter(creep => creep.spawning).length;
const decision = evaluateRoleReplacement({
targetCount: config.targetCount,
activeTtls: activeCreeps.map(creep => creep.ticksToLive),
spawningCount,
spawnTicks: config.body.length * CREEP_SPAWN_TIME,
spawnWaitTicks,
travelTicks: config.travelTicks,
safetyBuffer: config.safetyBuffer
});
if (!decision.valid || !decision.shouldSpawn) return null;
return {
role,
config,
replacementFor: activeCreeps[0]?.name ?? null,
...decision
};
}
function compareRequests(left, right) {
const leftTier = left.reason === 'count-below-target' ? 0 : 1;
const rightTier = right.reason === 'count-below-target' ? 0 : 1;
if (leftTier !== rightTier) return leftTier - rightTier;
if (leftTier === 0) {
return right.missingCount - left.missingCount
|| left.config.priority - right.config.priority
|| left.role.localeCompare(right.role);
}
return left.minimumSlack - right.minimumSlack
|| left.config.priority - right.config.priority
|| left.role.localeCompare(right.role);
}
function runRoomPrespawnManager(room) {
const state = getRoomState(room.name);
const verification = verifyPendingReplacement(state);
if (state.lastRunTick === Game.time) {
return { status: 'already-ran-this-tick', verification };
}
state.lastRunTick = Game.time;
const spawns = getUsableSpawns(room);
if (spawns.length === 0) return { status: 'no-usable-spawn', verification };
const spawnWaitTicks = Math.min(
...spawns.map(spawn => spawn.spawning?.remainingTime ?? 0)
);
const requests = Object.entries(ROLE_CONFIG)
.map(([role, config]) =>
createRoleRequest(room, role, config, spawnWaitTicks)
)
.filter(Boolean)
.sort(compareRequests);
if (requests.length === 0) return { status: 'no-request', verification };
const request = requests[0];
const spawn = spawns.find(item => !item.spawning);
if (!spawn) return { status: 'all-spawns-busy', request, verification };
const name = [request.role, room.name, spawn.name, Game.time].join('-');
const memory = {
role: request.role,
homeRoom: room.name,
replacementReason: request.reason,
replacementFor: request.replacementFor
};
const dryRunResult = spawn.spawnCreep(request.config.body, name, {
memory,
dryRun: true
});
if (dryRunResult !== OK) {
return { status: 'dry-run-failed', dryRunResult, request, verification };
}
const result = spawn.spawnCreep(request.config.body, name, { memory });
if (result === OK) {
state.pending = {
tick: Game.time,
name,
spawnName: spawn.name,
role: request.role,
reason: request.reason,
replacementFor: request.replacementFor
};
}
return {
status: result === OK ? 'spawn-submitted' : 'spawn-failed',
result,
dryRunResult,
name,
spawnName: spawn.name,
request,
verification
};
}
Run emergency recovery before this manager when a critical role is already absent. Run ordinary expansion requests only when neither emergency recovery nor prespawn replacement has used the room's final Spawn submission.
Preserve and verify Spawn results
| Result | Meaning for the queue |
|---|---|
OK | Save the accepted name and observe it later |
ERR_BUSY | Find another module that bypassed the manager |
ERR_NOT_ENOUGH_ENERGY | Use a smaller body or emergency policy where appropriate |
ERR_NAME_EXISTS | Fix naming or duplicate same-tick ownership |
ERR_INVALID_ARGS | Stop blind retries and repair configuration |
ERR_RCL_NOT_ENOUGH | Check RCL and structure activity |
replacement-observed only proves that the accepted name appeared in Game.creeps or spawn.spawning. It does not prove the new unit reached work before the old unit left.
Use the planner as an estimate
The Spawn Queue and Replacement Planner estimates body production time, normal versus CLAIM lifetime, average Spawn utilization, travel allowance, safety margin, and optional OPERATE_SPAWN planning.
It is not an exact runtime scheduler. It does not simulate simultaneous expirations, Energy starvation, blocked spawn directions, DISRUPT_SPAWN, competing modules, or the formal spawnCreep() result.
Evidence boundaries
Twenty offline cases passed, including healthy TTLs, threshold equality, shortages, no active Creep, spawning coverage, completed temporary surplus, multiple expirations, Spawn waiting, zero travel and buffer, invalid TTL and configuration inputs, and large finite values. The complete Chinese manager passed a JavaScript syntax check.
Live Console, official-shard Spawn competition, work-position arrival, PWR_OPERATE_SPAWN changes, hostile disruption, and true zero-downtime handoffs remain pending. A real no-gap claim requires the replacement's first work-position tick to occur before the old Creep's last work tick.