Quick answer
Do not let Harvester, Upgrader, remote, replacement, and defense modules call spawnCreep() independently. Let them emit demand records. One room scheduler validates those records, deduplicates a stable requestKey, sorts priority, assigns each idle Spawn once, maintains one local Energy budget for the shared Spawn and Extension pool, submits the command, and records the exact accepted name for later observation.
dryRun: true validates one request against the current script-visible state. It does not reserve Energy for another Spawn in the same room. The game loop reads a tick-start snapshot and resolves accumulated intents later, while multiple Spawns may draw from the same room Extensions. A scheduler therefore needs its own same-tick budget before it submits multiple requests.
Centralize demand before touching a Spawn
A role module should describe what is missing, not select a Spawn. Two independent modules can otherwise select the same Spawn, create duplicate role demand, invert emergency priority, or produce logs that cannot explain which request actually became a Creep.
function collectRoomDemand(room) {
const requests = [];
if (countWorkingRole(room, 'harvester') < 2) {
requests.push({
requestKey:
room.name + ':local:harvester:source-0',
roomName: room.name,
role: 'harvester',
body: [WORK, CARRY, MOVE],
priority: 700,
createdAt: Game.time,
expiresAt: Game.time + 10,
memory: {
role: 'harvester',
sourceIndex: 0
}
});
}
return requests;
}
The stable key represents one business obligation. A generated Creep name represents one attempted game object. Keep those identities separate.
Define one stable request contract
function calculateBodyCost(body) {
if (!Array.isArray(body)) {
return null;
}
let cost = 0;
for (const part of body) {
const partCost = BODYPART_COST[part];
if (!Number.isFinite(partCost)) {
return null;
}
cost += partCost;
}
return cost;
}
function validateSpawnRequest(request) {
if (
!request
|| typeof request.requestKey !== 'string'
|| request.requestKey.trim() === ''
) {
return {
ok: false,
reason: 'invalid-request-key'
};
}
if (
typeof request.roomName !== 'string'
|| !Game.rooms[request.roomName]
) {
return {
ok: false,
reason: 'room-not-visible'
};
}
if (
!Array.isArray(request.body)
|| request.body.length === 0
|| request.body.length > MAX_CREEP_SIZE
) {
return {
ok: false,
reason: 'invalid-body-length'
};
}
const cost = calculateBodyCost(request.body);
if (!Number.isInteger(cost) || cost <= 0) {
return {
ok: false,
reason: 'invalid-body-parts'
};
}
if (
!Number.isFinite(request.priority)
|| !Number.isInteger(request.createdAt)
|| !Number.isInteger(request.expiresAt)
|| request.expiresAt < request.createdAt
) {
return {
ok: false,
reason: 'invalid-scheduling-fields'
};
}
return {
ok: true,
cost
};
}
Useful request fields include requestKey, room, role, body, priority, creation and expiry ticks, initial Memory, optional directions, and optional preferred Spawn names. Reject malformed records at the queue boundary so one bad module does not stop the whole scheduler.
Deduplicate by business identity
function requestFingerprint(request) {
return JSON.stringify({
roomName: request.roomName,
role: request.role,
body: request.body,
memory: request.memory,
directions: request.directions ?? null
});
}
function deduplicateRequests(requests) {
const byKey = new Map();
const conflicts = [];
for (const request of requests) {
const previous = byKey.get(request.requestKey);
if (!previous) {
byKey.set(request.requestKey, request);
continue;
}
if (
requestFingerprint(previous)
!== requestFingerprint(request)
) {
conflicts.push({
requestKey: request.requestKey,
status: 'request-definition-conflict'
});
continue;
}
if (
request.priority > previous.priority
|| (
request.priority === previous.priority
&& request.createdAt < previous.createdAt
)
) {
byKey.set(request.requestKey, request);
}
}
return {
requests: [...byKey.values()],
conflicts
};
}
A new random key every tick disables deduplication. A Creep name also makes a poor demand key because replacements create new names while satisfying the same business slot.
Use stable priority and bounded aging
const SPAWN_PRIORITY = Object.freeze({
EMERGENCY_RECOVERY: 1000,
ACTIVE_DEFENSE: 900,
CONTROLLER_SAFETY: 800,
ESSENTIAL_ECONOMY: 700,
REPLACEMENT: 600,
NORMAL_ECONOMY: 400,
REMOTE_EXPANSION: 200,
OPTIONAL: 100
});
function effectivePriority(
request,
now,
options = {}
) {
const waitStep = options.waitStep ?? 50;
const maxBonus = options.maxBonus ?? 100;
const waited = Math.max(
0,
now - request.createdAt
);
return request.priority + Math.min(
maxBonus,
Math.floor(waited / waitStep)
);
}
function sortSpawnRequests(requests, now) {
return [...requests].sort((left, right) =>
effectivePriority(right, now)
- effectivePriority(left, now)
|| left.createdAt - right.createdAt
|| left.requestKey.localeCompare(
right.requestKey
)
);
}
A bounded waiting bonus prevents ordinary work from starving forever without allowing an optional Builder to overtake emergency recovery or active defense.
Reserve one shared room Energy budget
Extensions in one room may be used by multiple Spawns. The script sees the beginning-of-tick room state, and commands are resolved later. The local budget below prevents this scheduler from promising the same observed Energy twice.
function createRoomEnergyBudget(room) {
return {
roomName: room.name,
observedAt: Game.time,
observedEnergy: room.energyAvailable,
reservedEnergy: 0
};
}
function remainingBudget(budget) {
return Math.max(
0,
budget.observedEnergy
- budget.reservedEnergy
);
}
function reserveEnergy(budget, amount) {
if (
!Number.isInteger(amount)
|| amount <= 0
|| amount > remainingBudget(budget)
) {
return false;
}
budget.reservedEnergy += amount;
return true;
}
This is a scheduling invariant, not an official server lock. Other code outside the scheduler can still create conflicts. Keep all Spawn creation ownership in one place.
Use dryRun as validation, not a reservation
function submitSpawnRequest(
spawn,
request,
creepName,
budget
) {
const validation = validateSpawnRequest(
request
);
if (!validation.ok) {
return {
status: 'invalid-request',
reason: validation.reason
};
}
if (validation.cost > remainingBudget(budget)) {
return {
status: 'local-budget-insufficient',
cost: validation.cost,
remaining: remainingBudget(budget)
};
}
const options = {
memory: request.memory,
directions: request.directions
};
const dryRunResult = spawn.spawnCreep(
request.body,
creepName,
{
...options,
dryRun: true
}
);
if (dryRunResult !== OK) {
return {
status: 'dry-run-rejected',
result: dryRunResult
};
}
const result = spawn.spawnCreep(
request.body,
creepName,
options
);
if (result !== OK) {
return {
status: 'submission-rejected',
result
};
}
if (!reserveEnergy(budget, validation.cost)) {
return {
status: 'budget-invariant-failed',
result,
cost: validation.cost
};
}
return {
status: 'submitted-locally',
result,
requestKey: request.requestKey,
spawnName: spawn.name,
creepName,
cost: validation.cost,
submittedAt: Game.time
};
}
OK is an accepted local command state. It is not evidence that the next world state already contains an active Creep.
Assign every idle Spawn at most once
function getIdleSpawns(room) {
return room.find(FIND_MY_SPAWNS)
.filter(spawn =>
spawn.my
&& spawn.isActive()
&& !spawn.spawning
)
.sort((left, right) =>
left.name.localeCompare(right.name)
);
}
function rankSpawns(request, spawns) {
const preferred = new Set(
request.preferredSpawnNames ?? []
);
return [...spawns].sort((left, right) =>
Number(!preferred.has(left.name))
- Number(!preferred.has(right.name))
|| left.name.localeCompare(right.name)
);
}
Preference changes order; it should not normally turn an available emergency request into a no-Spawn state merely because its first choice is busy.
Track accepted names as pending evidence
function savePendingSpawn(outcome, request) {
if (outcome.status !== 'submitted-locally') {
return false;
}
Memory.spawnScheduler ??= {
version: 1,
pending: {},
completed: {}
};
Memory.spawnScheduler.pending[
outcome.requestKey
] = {
requestKey: outcome.requestKey,
roomName: request.roomName,
role: request.role,
spawnName: outcome.spawnName,
creepName: outcome.creepName,
cost: outcome.cost,
submittedAt: outcome.submittedAt,
lastCheckedAt: null
};
return true;
}
Pending records stop the demand collector from creating the same slot again while the accepted name has not yet appeared in the later observed state.
Verify spawning or release on later ticks
function verifyPendingSpawn(pending) {
const spawn = Game.spawns[
pending.spawnName
] ?? null;
const creep = Game.creeps[
pending.creepName
] ?? null;
if (
spawn?.spawning?.name
=== pending.creepName
) {
return {
status: 'spawning-observed',
requestKey: pending.requestKey,
remainingTime:
spawn.spawning.remainingTime
};
}
if (creep) {
return {
status: creep.spawning
? 'spawning-creep-observed'
: 'creep-released',
requestKey: pending.requestKey,
creepName: pending.creepName
};
}
return {
status: 'not-observed-yet',
requestKey: pending.requestKey,
creepName: pending.creepName
};
}
function pendingTimedOut(
pending,
now,
timeoutTicks = 2
) {
return now - pending.submittedAt
>= timeoutTicks;
}
Do not immediately retry not-observed-yet in the same tick. Give the accepted intent a later observation boundary, then record timeout evidence before requeueing.
Build the complete room scheduler
function buildCreepName(request, sequence) {
const role = String(request.role)
.replace(/[^A-Za-z0-9_-]/g, '-')
.slice(0, 24);
return [
role,
request.roomName,
Game.time,
sequence
].join('-').slice(0, 100);
}
function scheduleRoomSpawns(
room,
rawRequests
) {
const deduplicated = deduplicateRequests(
rawRequests
);
const requests = sortSpawnRequests(
deduplicated.requests.filter(request =>
request.roomName === room.name
&& request.expiresAt >= Game.time
),
Game.time
);
const idleSpawns = getIdleSpawns(room);
const assigned = new Set();
const budget = createRoomEnergyBudget(room);
const outcomes = [
...deduplicated.conflicts
];
let sequence = 0;
for (const request of requests) {
const candidates = rankSpawns(
request,
idleSpawns.filter(spawn =>
!assigned.has(spawn.name)
)
);
const spawn = candidates[0];
if (!spawn) {
outcomes.push({
status: 'no-idle-spawn',
requestKey: request.requestKey
});
continue;
}
sequence += 1;
const outcome = submitSpawnRequest(
spawn,
request,
buildCreepName(request, sequence),
budget
);
outcomes.push(outcome);
if (outcome.status === 'submitted-locally') {
assigned.add(spawn.name);
savePendingSpawn(outcome, request);
}
}
return {
roomName: room.name,
observedEnergy: budget.observedEnergy,
reservedEnergy: budget.reservedEnergy,
remainingEnergy: remainingBudget(budget),
outcomes
};
}
The loop continues after one malformed or rejected request. Whether an unaffordable high-priority request blocks smaller lower-priority work is a separate policy decision.
Choose blocking and fallback policy explicitly
Strict priority preserves Energy for a critical large body but may leave idle Spawn capacity. A skip policy improves utilization but may starve the large request. Add a request field such as blocking: true when an unaffordable critical request should stop lower-priority scheduling.
Emergency recovery should normally provide a minimum viable fallback body rather than waiting indefinitely for the full design. Combine this scheduler with the emergency recovery guide, dynamic body selection, and prespawn replacement.
Common failure modes
- Every role manager calls spawnCreep(): no global priority or deduplication exists.
- The key contains Game.time or randomness: the same demand becomes a new request every tick.
- Only dryRun is trusted: multiple Spawns can validate against the same observed room Energy.
- OK is renamed completed: accepted local submission and later world evidence are conflated.
- Spawning and pending coverage is ignored: duplicate replacements enter the queue.
- One Spawn is assigned twice: the scheduler does not track same-tick assignment.
- Requests never expire: cancelled remote plans can execute much later.
- Priority never ages or always skips: either small work starves or critical large bodies never accumulate Energy.
Evidence and production boundary
This revision checks the current official API, the documented tick-start snapshot and later intent resolution model, shared room Extension behavior, and the public engine's Spawn validation and Energy charging path. Repository tests syntax-check the examples and run deterministic cases for deduplication, fingerprint conflicts, stable sorting, bounded aging, room Energy reservation, one-assignment-per-Spawn, pending records, and later observation states.
Screeps Console evidence, official-shard same-tick multi-Spawn Energy contention, exact intent processing order, Power effects, and long-running CPU cost remain pending. The article therefore reports submitted-locally, spawning-observed, and creep-released as separate states.
Official references: StructureSpawn.spawnCreep(), Game.spawns, the game-loop model, shared Spawn and Extension Energy, and return-code debugging.