Use this guide when
You have two or more owned Links in one room and need one dispatcher to decide which source sends, how much target capacity is still available, and whether the next tick contains the exact source-to-target transfer event.
This guide assumes Link roles are already configured by ID. It does not try to infer a Controller Link or Source Link from array order or position.
Why independent senders race
Two modules can read the same target free capacity before either transfer settles. Both may decide that the full amount is available. A later call can then return ERR_FULL, or one module can overwrite the evidence record of another.
A local reservation is not a server lock. It is a same-tick planning rule used by one dispatcher so every source evaluates the target against capacity already promised by earlier plans.
Resolve explicit Link identities
function getOwnedLink(id, roomName) {
if (typeof id !== 'string') {
return null;
}
const link = Game.getObjectById(id);
if (
!link
|| link.structureType !== STRUCTURE_LINK
|| link.my !== true
|| link.room.name !== roomName
) {
return null;
}
return link;
}
function estimateLinkReceipt(requestedAmount) {
if (!Number.isInteger(requestedAmount) || requestedAmount <= 0) {
return {
requestedAmount: 0,
estimatedLoss: 0,
estimatedReceived: 0
};
}
const estimatedLoss = Math.ceil(
requestedAmount * LINK_LOSS_RATIO
);
return {
requestedAmount,
estimatedLoss,
estimatedReceived: Math.max(
0,
requestedAmount - estimatedLoss
)
};
}
The loss calculation is useful for planning and logs. The next-tick event and current Stores remain the evidence. Do not rewrite a predicted target Store value into Memory.
Reserve shared target capacity
The planner below sorts requests once, rejects inactive or cooling source Links, and reduces the target's remaining capacity after every accepted plan.
function planLinkTransfers(roomName, config) {
const target = getOwnedLink(
config.targetLinkId,
roomName
);
if (!target || target.isActive() !== true) {
return {
status: 'target-unavailable',
target: null,
plans: []
};
}
const targetReserve = Number.isInteger(config.targetReserve)
? Math.max(0, config.targetReserve)
: 0;
const minimumSend = Number.isInteger(config.minimumSend)
? Math.max(1, config.minimumSend)
: 1;
let remainingTargetCapacity = Math.max(
0,
target.store.getFreeCapacity(RESOURCE_ENERGY)
- targetReserve
);
const requests = [...config.sources]
.filter(request =>
typeof request.sourceLinkId === 'string'
&& Number.isFinite(request.priority)
)
.sort((left, right) =>
right.priority - left.priority
|| left.sourceLinkId.localeCompare(
right.sourceLinkId
)
);
const seenSources = new Set();
const plans = [];
for (const request of requests) {
if (seenSources.has(request.sourceLinkId)) {
continue;
}
seenSources.add(request.sourceLinkId);
const source = getOwnedLink(
request.sourceLinkId,
roomName
);
if (
!source
|| source.id === target.id
|| source.isActive() !== true
|| source.cooldown > 0
) {
continue;
}
const sourceEnergy = source.store.getUsedCapacity(
RESOURCE_ENERGY
);
const requestedAmount = Math.min(
sourceEnergy,
remainingTargetCapacity
);
if (requestedAmount < minimumSend) {
continue;
}
const estimate = estimateLinkReceipt(requestedAmount);
plans.push({
sourceId: source.id,
targetId: target.id,
requestedAmount,
estimate
});
remainingTargetCapacity = Math.max(
0,
remainingTargetCapacity
- requestedAmount
);
}
return {
status: plans.length > 0
? 'plans-ready'
: 'no-transfer-ready',
target,
plans,
targetReserve,
minimumSend,
remainingTargetCapacity
};
}
The requested amount is deliberately conservative: it never exceeds visible source stock or unreserved target capacity. The reservation subtracts the requested amount, not the predicted post-loss receipt. That means this planner does not recycle loss-created capacity inside the same planning pass or depend on Link intent-processing order to make later requests fit. It can leave target capacity unused after loss, which is an intentional safety tradeoff.
Submit each source once
function submitLinkPlans(roomName, state, planned) {
const submissions = [];
for (const plan of planned.plans) {
const source = getOwnedLink(plan.sourceId, roomName);
const target = getOwnedLink(plan.targetId, roomName);
if (!source || !target) {
submissions.push({
status: 'link-disappeared',
sourceId: plan.sourceId,
targetId: plan.targetId
});
continue;
}
const before = {
sourceEnergy: source.store.getUsedCapacity(
RESOURCE_ENERGY
),
targetEnergy: target.store.getUsedCapacity(
RESOURCE_ENERGY
),
targetFree: target.store.getFreeCapacity(
RESOURCE_ENERGY
),
sourceCooldown: source.cooldown
};
const result = source.transferEnergy(
target,
plan.requestedAmount
);
const submission = {
requestId: [
roomName,
Game.time,
source.id,
target.id
].join(':'),
submittedAt: Game.time,
sourceId: source.id,
targetId: target.id,
requestedAmount: plan.requestedAmount,
estimate: plan.estimate,
result,
before
};
if (result === OK) {
state.pending.push(submission);
}
submissions.push({
...submission,
status: result === OK
? 'transfer-accepted'
: 'transfer-rejected'
});
}
return submissions;
}
The planner deduplicates source IDs, and the dispatcher is the only module that calls transferEnergy(). A source Link that is accepted becomes unavailable through cooldown; same-tick coordination should not depend on discovering that after another caller has already acted.
Separate requested Link Energy from processed Energy
transferEnergy() returning OK proves that the request passed the call-time checks. It does not freeze the target Store for processing. In the checked engine snapshot, the Link processor re-reads the target's current Energy and capacity and can truncate the processed amount to the free capacity that remains at processing time.
The processor applies Math.ceil(processedAmount × LINK_LOSS_RATIO) after that truncation, so loss should be reconciled from the processed event amount, not blindly from the original request. The source cooldown is based on Link distance, not on how much Energy ultimately fits. A matched EVENT_TRANSFER therefore carries the stronger amount evidence for the processed source-target action.
Verify exact transfer events
For every accepted call, match the next tick's EVENT_TRANSFER by source object ID, target ID, and Energy resource type. Event-reported amount and net Store changes are retained as separate fields.
function verifyLinkTransfers(room, state) {
const events = room.getEventLog();
const results = [];
const keep = [];
for (const pending of state.pending) {
if (Game.time <= pending.submittedAt) {
keep.push(pending);
continue;
}
if (Game.time !== pending.submittedAt + 1) {
results.push({
status: 'verification-window-missed',
requestId: pending.requestId
});
continue;
}
const matches = events.filter(event =>
event.event === EVENT_TRANSFER
&& event.objectId === pending.sourceId
&& event.data?.targetId === pending.targetId
&& event.data?.resourceType === RESOURCE_ENERGY
);
const source = Game.getObjectById(pending.sourceId);
const target = Game.getObjectById(pending.targetId);
const netState = {
sourceEnergyNow: source
? source.store.getUsedCapacity(RESOURCE_ENERGY)
: null,
targetEnergyNow: target
? target.store.getUsedCapacity(RESOURCE_ENERGY)
: null,
sourceCooldownNow: source?.cooldown ?? null
};
if (matches.length === 1) {
results.push({
status: 'transfer-event-verified',
requestId: pending.requestId,
requestedAmount: pending.requestedAmount,
processedAmount: matches[0].data?.amount ?? null,
processedEstimate: estimateLinkReceipt(
matches[0].data?.amount ?? 0
),
eventData: matches[0].data || null,
before: pending.before,
netState
});
} else {
results.push({
status: matches.length === 0
? 'accepted-event-not-found'
: 'ambiguous-transfer-events',
requestId: pending.requestId,
matchCount: matches.length,
netState
});
}
}
state.pending = keep;
return results;
}
Another Creep or Link may change either Store in the same tick. Exact event identity proves the actor-target pair; Store deltas remain operational context rather than exclusive attribution.
Complete Link-network loop
function runLinkNetwork(roomName) {
Memory.linkNetworks ??= {};
const state = Memory.linkNetworks[roomName] ??= {
enabled: true,
targetLinkId: 'replace-with-target-link-id',
targetReserve: 0,
minimumSend: 200,
sources: [],
pending: []
};
if (state.enabled !== true) {
return { status: 'disabled' };
}
state.pending = Array.isArray(state.pending)
? state.pending
: [];
if (state.lastDispatchAt === Game.time) {
return { status: 'already-dispatched-this-tick' };
}
state.lastDispatchAt = Game.time;
const room = Game.rooms[roomName];
if (!room) {
return { status: 'room-not-visible' };
}
const verification = verifyLinkTransfers(room, state);
const planned = planLinkTransfers(roomName, state);
const submissions = planned.status === 'plans-ready'
? submitLinkPlans(roomName, state, planned)
: [];
state.lastRunAt = Game.time;
state.lastPlanStatus = planned.status;
state.lastVerification = verification;
return {
status: submissions.length > 0
? 'submissions-complete'
: planned.status,
verification,
submissions,
remainingTargetCapacity:
planned.remainingTargetCapacity ?? null
};
}
module.exports.loop = function () {
const outcome = runLinkNetwork('W1N1');
if (
outcome.status === 'target-unavailable'
|| outcome.submissions?.some(
item => item.status === 'transfer-rejected'
)
|| Game.time % 100 === 0
) {
console.log(JSON.stringify({
type: 'link-network',
gameTick: Game.time,
outcome
}));
}
};
Example source configuration:
Memory.linkNetworks.W1N1.sources = [
{
sourceLinkId: 'replace-with-first-source-link-id',
priority: 100
},
{
sourceLinkId: 'replace-with-second-source-link-id',
priority: 80
}
];
Return-code boundaries
| Result | Likely boundary | Review |
|---|---|---|
OK | The transfer intent was accepted | Match the next-tick event |
ERR_NOT_OWNER | The source Link is not yours | Source ID and ownership |
ERR_NOT_ENOUGH_RESOURCES | Source Energy changed before the call | Current source Store and competing code |
ERR_INVALID_TARGET | The target is not a valid Link | Current target object and type |
ERR_FULL | The target cannot accept the requested transfer | Shared reservations and other callers |
ERR_NOT_IN_RANGE | The Links are not in the same room | Both room names |
ERR_INVALID_ARGS | The amount is invalid | Positive integer request |
ERR_TIRED | The source Link is cooling down | source.cooldown |
ERR_RCL_NOT_ENOUGH | A Link is inactive at the current RCL | isActive() and room level |
Production adaptation notes
- Use one dispatcher for every Link action in the room. A reservation map cannot coordinate code that bypasses it.
- Priorities, reserve, and minimum send are policies. A Controller Link, Storage Link, and upgrade burst do not share one universal threshold.
- Path and range do not determine Link cooldown in this article. Read the live cooldown after settlement rather than logging a guessed ready tick.
- Keep accepted requests until exactly the next tick. Do not retain them indefinitely and later claim a matching net Store change.
- Private servers may change constants. Treat the current environment's constants as the calculation source.
Choose another guide when
Use the Controller recovery guide when Energy reaches the Controller area but upgrade intents are still not verified. Use the Storage Energy policy guide when the unresolved decision is whether the room can afford the transfer. Use the event-log ingestion guide when several systems need one shared, idempotent event reader.