Debugging fast path: confirm the symptom first
Start here before changing a production planner. Read the active Spawn lifecycle, the named Creep, and all eight adjacent tiles in the same tick. This is read-only diagnostic evidence.
const ALL_SPAWN_DIRECTIONS = [
TOP,
TOP_RIGHT,
RIGHT,
BOTTOM_RIGHT,
BOTTOM,
BOTTOM_LEFT,
LEFT,
TOP_LEFT
];
const SPAWN_DIRECTION_OFFSETS = {
[TOP]: [0, -1],
[TOP_RIGHT]: [1, -1],
[RIGHT]: [1, 0],
[BOTTOM_RIGHT]: [1, 1],
[BOTTOM]: [0, 1],
[BOTTOM_LEFT]: [-1, 1],
[LEFT]: [-1, 0],
[TOP_LEFT]: [-1, -1]
};
function structureBlocksSpawnExit(structure) {
if (structure.structureType === STRUCTURE_RAMPART) {
return !structure.my && !structure.isPublic;
}
return OBSTACLE_OBJECT_TYPES.includes(
structure.structureType
);
}
function inspectSpawnExitTile(spawn, direction) {
const offset = SPAWN_DIRECTION_OFFSETS[direction];
if (!offset) {
return {
direction,
status: 'direction-invalid',
stablePassable: false,
currentlyOpen: false
};
}
const x = spawn.pos.x + offset[0];
const y = spawn.pos.y + offset[1];
if (x < 0 || x > 49 || y < 0 || y > 49) {
return {
direction,
x,
y,
status: 'outside-room',
stablePassable: false,
currentlyOpen: false
};
}
const structures = spawn.room.lookForAt(
LOOK_STRUCTURES,
x,
y
);
const sites = spawn.room.lookForAt(
LOOK_CONSTRUCTION_SITES,
x,
y
);
const creeps = spawn.room.lookForAt(
LOOK_CREEPS,
x,
y
);
const powerCreeps = spawn.room.lookForAt(
LOOK_POWER_CREEPS,
x,
y
);
const hasRoad = structures.some(
structure =>
structure.structureType === STRUCTURE_ROAD
);
const terrain = spawn.room.getTerrain().get(x, y);
const terrainBlocked =
(terrain & TERRAIN_MASK_WALL) !== 0
&& !hasRoad;
const stableBlocked =
terrainBlocked
|| structures.some(structureBlocksSpawnExit)
|| sites.some(site =>
OBSTACLE_OBJECT_TYPES.includes(
site.structureType
)
);
const occupied =
creeps.length > 0
|| powerCreeps.length > 0;
return {
direction,
x,
y,
status: stableBlocked
? 'stable-obstacle'
: occupied
? 'temporarily-occupied'
: 'open-now',
stablePassable: !stableBlocked,
currentlyOpen: !stableBlocked && !occupied,
creepNames: creeps.map(creep => creep.name),
powerCreepNames: powerCreeps.map(creep => creep.name)
};
}
const spawn = Object.values(Game.spawns)[0];
if (!spawn) {
console.log('No owned Spawn is visible.');
} else {
const name = spawn.spawning?.name ?? null;
console.log(JSON.stringify({
tick: Game.time,
spawn: spawn.name,
name,
remainingTime:
spawn.spawning?.remainingTime ?? null,
directions:
spawn.spawning?.directions ?? null,
creepVisible:
name ? Boolean(Game.creeps[name]) : false,
tiles: ALL_SPAWN_DIRECTIONS.map(direction =>
inspectSpawnExitTile(spawn, direction)
)
}, null, 2));
}
If there is no active spawn.spawning, this is not an active Spawn-completion wait. If there is an active name, compare remainingTime, directions, Game.creeps[name], and the eight tile states before deciding what to change.
The tile scan is a script-visible diagnostic approximation, not the engine's private movement-reservation state. Same-tick movement can change occupancy before completion is processed.
Likely causes: symptom → evidence → cause → fix
| Symptom | Evidence to read | Likely cause | Fix |
|---|---|---|---|
Allowed tiles stay stable-obstacle | Terrain, structures, construction sites | Layout blocks the configured exits | Change the layout or permit a structurally usable direction. |
| Allowed tiles are structurally valid but occupied | Creep/Power Creep names on the eight tiles | Temporary traffic | Move traffic away or keep additional valid directions available. |
| Only one allowed direction waits while another adjacent tile is open | spawn.spawning.directions plus tile scan | Deliberately strict direction policy | Add fallback directions if strict one-exit behavior is not intentional. |
The final real spawnCreep() returned an error | Exact final return code | The request never entered this lifecycle | Fix the rejection with the return-code guide; do not start a birth observer. |
| The Creep is already born but later does not move | Game.creeps[name], creep.spawning, later position | Ordinary movement/pathing problem | Switch to the movement guide. |
What Spawn completion actually does
spawnCreep() === OK means the request was scheduled. It does not prove that the Creep has completed spawning or appeared on an adjacent tile. Likewise, setDirections() === OK means the direction update intent was accepted, not that a listed tile is free at settlement time.
In the pinned public engine source, completion checks the configured directions in order against stable obstacles, blocking construction sites, terrain, and movement occupancy. When an allowed tile succeeds, the Creep is moved there and spawning becomes false. If no allowed tile succeeds, the ordinary path can remain incomplete for a later tick.
Hostile occupancy is a verified engine-source exception. While checking the allowed directions, the engine remembers the first hostile Creep it finds. If all allowed directions fail and there is no usable opening among the directions that were excluded by policy, the engine can spawn-stomp that hostile Creep and complete the new Creep on that tile. If an excluded direction is actually open, the engine does not use the stomp shortcut and leaves completion unfinished. This behavior is verified from the pinned engine source; a controlled real-shard hostile-occupancy/spawn-stomp reproduction has not been collected for this article.
Keep the directions contract explicit
The helper below has one simple contract: when you pass an array, it keeps only the valid unique directions from that array and preserves their order. When you omit the array, it defaults to all eight directions. It does not silently add fallbacks to an explicit policy.
function normalizeSpawnDirections(input) {
const source = Array.isArray(input)
? input
: ALL_SPAWN_DIRECTIONS;
const valid = [];
const seen = new Set();
for (const direction of source) {
if (
Number.isInteger(direction)
&& direction >= TOP
&& direction <= TOP_LEFT
&& !seen.has(direction)
) {
seen.add(direction);
valid.push(direction);
}
}
return valid;
}
Therefore [RIGHT] is a deliberate strict one-exit policy, while omitted input means “consider all eight.” An explicit empty or all-invalid array produces no allowed direction, which the planner rejects instead of silently widening the policy.
Build one deterministic exit plan
After the fast diagnostic confirms an exit problem, reuse the same tile inspection for a small deterministic plan. Stable blockers are excluded. Currently open tiles come before temporarily occupied but structurally valid tiles, while the explicit direction order remains stable inside each group.
function planSpawnExitDirections(
spawn,
preferredDirections
) {
const snapshots = normalizeSpawnDirections(
preferredDirections
).map(direction =>
inspectSpawnExitTile(spawn, direction)
);
const stable = snapshots.filter(
item => item.stablePassable
);
const open = stable.filter(
item => item.currentlyOpen
);
const occupied = stable.filter(
item => !item.currentlyOpen
);
return {
status: stable.length === 0
? 'no-stable-exit'
: open.length === 0
? 'temporary-occupancy-only'
: 'exit-plan-ready',
directions: [
...open,
...occupied
].map(item => item.direction),
snapshots
};
}
Keeping temporarily occupied but structurally valid directions at the end is useful for non-strict policies because current traffic may move before a long spawn finishes. The planner still respects a deliberately narrow explicit array.
Keep dryRun and real submission separate
Use one plan for both calls. dryRun checks the request without scheduling it; only the final real call can create an active lifecycle record. The final return code can still differ because same-tick code may consume the Spawn, Energy, or name between calls.
function submitSpawnWithExitPlan({
spawn,
body,
name,
memory,
preferredDirections
}) {
if (!spawn?.my) {
return {
status: 'owned-spawn-required',
result: null
};
}
const plan = planSpawnExitDirections(
spawn,
preferredDirections
);
if (plan.directions.length === 0) {
return {
status: plan.status,
result: null,
plan
};
}
const options = {
memory,
directions: plan.directions
};
const dryRunResult = spawn.spawnCreep(
body,
name,
{
...options,
dryRun: true
}
);
if (dryRunResult !== OK) {
return {
status: 'dry-run-rejected',
result: dryRunResult,
plan
};
}
Memory.spawnExitChecks ??= {};
delete Memory.spawnExitChecks[name];
const result = spawn.spawnCreep(
body,
name,
options
);
if (result !== OK) {
return {
status: 'spawn-submit-rejected',
result,
plan
};
}
Memory.spawnExitChecks[name] = {
spawnId: spawn.id,
name,
submittedAt: Game.time,
directions: [...plan.directions],
acceptedResult: result,
lastObservedAt: Game.time,
nearCompleteTicks: 0
};
return {
status: 'spawn-scheduled',
result,
plan
};
}
A rejected final submission preserves the real return code in the returned result but does not enter the “waiting for birth” observer state.
Refresh directions near completion
If room traffic changes during a long spawn, one Spawn coordinator can refresh the same policy near completion. Do not let unrelated role modules compete to overwrite the list.
function refreshSpawnExitDirections(
spawn,
preferredDirections,
refreshAtRemainingTime = 3
) {
if (!spawn?.spawning) {
return {
status: 'no-active-spawn',
result: null
};
}
if (
!Number.isInteger(refreshAtRemainingTime)
|| refreshAtRemainingTime < 0
) {
return {
status: 'refresh-threshold-invalid',
result: null
};
}
if (
spawn.spawning.remainingTime
> refreshAtRemainingTime
) {
return {
status: 'refresh-not-due',
result: null
};
}
const plan = planSpawnExitDirections(
spawn,
preferredDirections
);
if (plan.directions.length === 0) {
return {
status: plan.status,
result: null,
plan
};
}
const result = spawn.spawning.setDirections(
plan.directions
);
return {
status: result === OK
? 'directions-refresh-accepted'
: 'directions-refresh-rejected',
result,
plan
};
}
The same evidence rule applies here: an accepted setDirections() call records an accepted intent, not a completed exit.
Observe completion across ticks
Only observe names that have an active record created after a real spawnCreep() === OK. Repeated near-complete observations can support the local label completion-retry-observed; they do not identify the blocker without the corresponding tile state.
function observeSpawnExit(name) {
Memory.spawnExitChecks ??= {};
const record = Memory.spawnExitChecks[name];
if (!record) {
return {
status: 'spawn-exit-record-missing'
};
}
const spawn = Game.getObjectById(record.spawnId);
const creep = Game.creeps[name];
if (
spawn?.spawning
&& spawn.spawning.name === name
) {
const nearComplete =
spawn.spawning.remainingTime <= 1;
record.nearCompleteTicks = nearComplete
? (record.nearCompleteTicks ?? 0) + 1
: 0;
record.lastObservedAt = Game.time;
record.lastRemainingTime =
spawn.spawning.remainingTime;
return {
status: record.nearCompleteTicks >= 2
? 'completion-retry-observed'
: 'still-spawning',
remainingTime:
spawn.spawning.remainingTime,
directions:
spawn.spawning.directions ?? null,
nearCompleteTicks:
record.nearCompleteTicks
};
}
if (creep?.spawning) {
return {
status: 'creep-still-inside-spawn'
};
}
if (creep && spawn) {
const dx = creep.pos.x - spawn.pos.x;
const dy = creep.pos.y - spawn.pos.y;
const direction = Object.entries(
SPAWN_DIRECTION_OFFSETS
).find(([, offset]) =>
offset[0] === dx
&& offset[1] === dy
)?.[0];
return {
status: direction
? 'born-on-observable-adjacent-tile'
: 'born-but-exit-direction-missed',
direction: direction
? Number(direction)
: null,
wasPlanned: direction
? record.directions.includes(
Number(direction)
)
: null
};
}
return {
status: 'completion-unverified',
lastObservedAt: record.lastObservedAt
};
}
The Creep may receive a movement intent immediately after birth. If it has already left range 1 before your later observation, record that the birth direction was missed instead of inventing one from a later position.
Retest after the fix
- Same tick: record the exact
dryRun, realspawnCreep(), orsetDirections()return code. That is call evidence only. - Later tick: re-read
spawn.spawning?.name,spawn.spawning?.remainingTime,spawn.spawning?.directions,Game.creeps[name], and any adjacent tile state needed to explain the result. - Do not rename an accepted request or direction update as “Creep born.” The observed later world state is separate evidence.
Evidence boundary and official sources
The public API contract and the pinned Screeps engine source were checked for direction ordering, completion occupancy, and the hostile-Creep spawn-stomp branch. JavaScript blocks and deterministic planner/lifecycle cases are checked statically. Genuine Screeps Console output, controlled official-shard completion retries, same-tick traffic races, Power Creep occupancy, and a live hostile-occupancy/spawn-stomp reproduction remain Pending.
Official references: StructureSpawn.spawnCreep(), StructureSpawn.Spawning, setDirections(), the game-loop model, and the pinned Spawn completion source.