Quick answer
Treat InterShardMemory as one versioned, shard-owned publication per shard. The current shard may replace only its own string; other shards can read that string but cannot edit it. Put a schema version, source shard, writer epoch, envelope revision, and channel revisions around the data, enforce a conservative UTF-8 byte budget, and keep remote freshness in the receiver's local Memory.
Do not subtract a remote writtenAtTick from the local Game.time unless you have separately proven that clock relationship. The official API documents isolated shard execution and storage, but it does not promise one shared tick clock for application-level freshness decisions.
Separate Memory, Segments, and InterShardMemory
| Container | Write scope | Primary use |
|---|---|---|
Memory | Current shard | Frequently used persistent state |
RawMemory.segments | Current shard | Large data activated on demand |
InterShardMemory | Current shard writes its own string; remote strings are read-only | Cross-shard state exchange |
The API currently gives every shard a separate 100 KB string. setLocal() replaces the whole local string, so one coordinator must merge all channel updates before writing.
Create a writer epoch for stream restarts
function createWriterEpoch(shardName, now) {
if (
typeof shardName !== 'string'
|| shardName.trim() === ''
|| !Number.isInteger(now)
) {
return null;
}
return shardName + ':' + now;
}
A revision can restart at zero after a local reset or migration. The writer epoch distinguishes a new stream from an invalid revision regression. Reuse the existing epoch while the local envelope is valid; create a new one only when starting a new local stream.
Measure UTF-8 bytes before setLocal()
function utf8ByteLength(value) {
const text = String(value);
let bytes = 0;
for (let index = 0; index < text.length; index += 1) {
const code = text.charCodeAt(index);
if (code < 0x80) {
bytes += 1;
} else if (code < 0x800) {
bytes += 2;
} else if (
code >= 0xD800
&& code <= 0xDBFF
&& index + 1 < text.length
&& text.charCodeAt(index + 1) >= 0xDC00
&& text.charCodeAt(index + 1) <= 0xDFFF
) {
bytes += 4;
index += 1;
} else {
bytes += 3;
}
}
return bytes;
}
JavaScript string length counts UTF-16 code units, not UTF-8 bytes. The official API documents a 100 KB string but the reviewed documentation does not define the server-side accounting algorithm. This guide therefore uses exact UTF-8 accounting only for its own conservative 96 KiB project budget; 96 KiB is not presented as another official Screeps limit.
Define one versioned envelope
function createEmptyEnvelope(
shardName,
writerEpoch,
now
) {
return {
schemaVersion: 1,
sourceShard: shardName,
writerEpoch,
revision: 0,
writtenAtTick: now,
channels: {}
};
}
The envelope owns global schema and stream identity. Each business channel should also own a monotonically increasing channel revision so unrelated channel updates do not make stale data appear fresh.
Parse without erasing damaged evidence
function parseInterShardEnvelope(
raw,
expectedShard
) {
if (raw == null || raw === '') {
return {
status: 'empty',
envelope: null
};
}
if (typeof raw !== 'string') {
return {
status: 'invalid-raw-type',
envelope: null
};
}
let value;
try {
value = JSON.parse(raw);
} catch {
return {
status: 'invalid-json',
envelope: null
};
}
if (
!value
|| typeof value !== 'object'
|| Array.isArray(value)
) {
return {
status: 'invalid-envelope',
envelope: null
};
}
if (value.schemaVersion !== 1) {
return {
status: 'unsupported-schema',
envelope: null
};
}
if (value.sourceShard !== expectedShard) {
return {
status: 'source-shard-mismatch',
envelope: null
};
}
if (
typeof value.writerEpoch !== 'string'
|| value.writerEpoch === ''
|| !Number.isInteger(value.revision)
|| value.revision < 0
|| !value.channels
|| typeof value.channels !== 'object'
|| Array.isArray(value.channels)
) {
return {
status: 'invalid-envelope-fields',
envelope: null
};
}
return {
status: 'valid',
envelope: value
};
}
Keep empty data, invalid JSON, unsupported schemas, source mismatches, and malformed fields as separate states. Do not turn a damaged local string into an empty object and immediately overwrite it.
Load and publish the local document
let interShardDraftTick = null;
let interShardDraftEnvelope = null;
function loadLocalEnvelope() {
const shardName = Game.shard.name;
if (
interShardDraftTick === Game.time
&& interShardDraftEnvelope
) {
return {
status: 'loaded-draft',
envelope: interShardDraftEnvelope
};
}
const raw = InterShardMemory.getLocal();
const parsed = parseInterShardEnvelope(
raw,
shardName
);
if (parsed.status === 'valid') {
Memory.interShard ??= {};
Memory.interShard.writerEpoch =
parsed.envelope.writerEpoch;
interShardDraftTick = Game.time;
interShardDraftEnvelope = parsed.envelope;
return {
status: 'loaded',
envelope: parsed.envelope
};
}
if (parsed.status !== 'empty') {
return {
status: 'local-data-invalid',
reason: parsed.status,
envelope: null
};
}
Memory.interShard ??= {};
const writerEpoch = createWriterEpoch(
shardName,
Game.time
);
if (writerEpoch === null) {
return {
status: 'writer-epoch-unavailable',
envelope: null
};
}
Memory.interShard.writerEpoch = writerEpoch;
const envelope = createEmptyEnvelope(
shardName,
writerEpoch,
Game.time
);
interShardDraftTick = Game.time;
interShardDraftEnvelope = envelope;
return {
status: 'created-empty',
envelope
};
}const INTERSHARD_SAFE_BYTE_LIMIT = 96 * 1024;
function publishLocalChannel(
channelName,
nextValue
) {
if (
typeof channelName !== 'string'
|| channelName.trim() === ''
) {
return {
status: 'invalid-channel-name'
};
}
const loaded = loadLocalEnvelope();
if (!loaded.envelope) {
return loaded;
}
const previousChannel =
loaded.envelope.channels[channelName];
const nextChannelRevision =
Number.isInteger(previousChannel?.revision)
? previousChannel.revision + 1
: 1;
const nextEnvelope = {
...loaded.envelope,
revision: loaded.envelope.revision + 1,
writtenAtTick: Game.time,
channels: {
...loaded.envelope.channels,
[channelName]: {
revision: nextChannelRevision,
updatedAtTick: Game.time,
value: nextValue
}
}
};
const serialized = JSON.stringify(nextEnvelope);
const byteLength = utf8ByteLength(serialized);
if (byteLength > INTERSHARD_SAFE_BYTE_LIMIT) {
return {
status: 'payload-too-large',
byteLength
};
}
InterShardMemory.setLocal(serialized);
interShardDraftTick = Game.time;
interShardDraftEnvelope = nextEnvelope;
return {
status: 'local-write-called',
byteLength,
envelopeRevision: nextEnvelope.revision,
channelRevision: nextChannelRevision
};
}
setLocal() has no documented OK return code. The coordinator above also keeps the newest envelope in a tick-scoped draft, so later channel updates merge that staged document instead of assuming a documented setLocal()-then-getLocal() read-after-write contract. The status local-write-called therefore describes only the local function call. It is not evidence that another shard has already observed the new revision.
Read a remote channel as read-only data
function readRemoteChannel(
remoteShard,
channelName
) {
if (
typeof remoteShard !== 'string'
|| remoteShard === ''
|| remoteShard === Game.shard.name
) {
return {
status: 'invalid-remote-shard'
};
}
const raw = InterShardMemory.getRemote(
remoteShard
);
const parsed = parseInterShardEnvelope(
raw,
remoteShard
);
if (parsed.status !== 'valid') {
return {
status: parsed.status,
remoteShard
};
}
const channel =
parsed.envelope.channels[channelName];
if (
!channel
|| !Number.isInteger(channel.revision)
|| channel.revision < 0
) {
return {
status: 'channel-missing',
remoteShard,
writerEpoch:
parsed.envelope.writerEpoch
};
}
return {
status: 'channel-read',
remoteShard,
writerEpoch:
parsed.envelope.writerEpoch,
envelopeRevision:
parsed.envelope.revision,
channelRevision: channel.revision,
value: channel.value
};
}
Validate the declared source shard before accepting a remote channel. A shard must never attempt to update another shard's publication; acknowledgements and responses belong in the receiver's own local document.
Use a local observation window
function observeRemoteChannel(
remoteShard,
channelName,
maxSilentTicks = 100
) {
const result = readRemoteChannel(
remoteShard,
channelName
);
if (result.status !== 'channel-read') {
return result;
}
Memory.interShardObservers ??= {};
const key = remoteShard + ':' + channelName;
const previous =
Memory.interShardObservers[key];
const streamChanged =
!previous
|| previous.writerEpoch
!== result.writerEpoch;
if (
!streamChanged
&& result.channelRevision
< previous.channelRevision
) {
return {
status: 'revision-regressed',
remoteShard,
channelName,
previousRevision:
previous.channelRevision,
observedRevision:
result.channelRevision
};
}
const advanced =
streamChanged
|| result.channelRevision
> previous.channelRevision;
const next = {
writerEpoch: result.writerEpoch,
channelRevision:
result.channelRevision,
lastCheckedAt: Game.time,
lastAdvancedAt: advanced
? Game.time
: previous.lastAdvancedAt
};
Memory.interShardObservers[key] = next;
if (
!advanced
&& Game.time - next.lastAdvancedAt
> maxSilentTicks
) {
return {
...result,
status: 'channel-stale',
silentTicks:
Game.time - next.lastAdvancedAt
};
}
return {
...result,
status: streamChanged
? 'stream-started'
: advanced
? 'channel-advanced'
: 'channel-unchanged',
silentTicks:
Game.time - next.lastAdvancedAt
};
}
The receiver records when a remote channel revision last advanced in its own local tick space. An unchanged revision can become channel-stale, but that state means only that this receiver has not observed progress within its configured window. It does not prove that the remote shard is offline.
Use offer and acknowledgement channels
function buildOutboundHandoff(
creep,
targetShard
) {
if (
!creep
|| creep.my !== true
|| typeof targetShard !== 'string'
|| targetShard === ''
) {
return null;
}
return {
handoffId:
Game.shard.name
+ ':'
+ creep.name
+ ':'
+ Game.time,
creepName: creep.name,
sourceShard: Game.shard.name,
targetShard,
state: 'offered',
offeredAtTick: Game.time,
memory: {
role: creep.memory.role ?? null,
missionId:
creep.memory.missionId ?? null
}
};
}
buildOutboundHandoff() only creates one offer record. The source coordinator should merge it into the existing handoffOffers map, apply pruneRecordMap(), and publish the bounded map from its own shard rather than replacing other unresolved offers.
function acknowledgeRemoteHandoff(
offer,
observedCreep
) {
if (
!offer
|| offer.targetShard
!== Game.shard.name
|| !observedCreep
|| observedCreep.my !== true
|| observedCreep.name
!== offer.creepName
) {
return {
status: 'handoff-not-confirmed'
};
}
const acknowledgement = {
handoffId: offer.handoffId,
sourceShard: offer.sourceShard,
targetShard: Game.shard.name,
creepName: observedCreep.name,
state: 'observed-on-target',
observedAtTick: Game.time
};
const loaded = loadLocalEnvelope();
if (!loaded.envelope) {
return loaded;
}
const previousValue =
loaded.envelope.channels
.handoffAcknowledgements?.value;
const previousRecords =
previousValue
&& typeof previousValue === 'object'
&& !Array.isArray(previousValue)
? previousValue
: {};
return publishLocalChannel(
'handoffAcknowledgements',
pruneRecordMap({
...previousRecords,
[offer.handoffId]: acknowledgement
})
);
}
For a Creep handoff, the source publishes an offer. The destination reads it, waits until it observes the exact Creep, then publishes an acknowledgement in its own local channel. The source later reads that acknowledgement. Neither side writes the other's data.
Keep the publication bounded
function pruneRecordMap(
records,
maxRecords = 32
) {
if (
!records
|| typeof records !== 'object'
|| Array.isArray(records)
) {
return {};
}
return Object.fromEntries(
Object.entries(records)
.sort((left, right) => {
const leftTick =
left[1]?.updatedAtTick
?? left[1]?.observedAtTick
?? left[1]?.offeredAtTick
?? 0;
const rightTick =
right[1]?.updatedAtTick
?? right[1]?.observedAtTick
?? right[1]?.offeredAtTick
?? 0;
return rightTick - leftTick
|| left[0].localeCompare(right[0]);
})
.slice(0, maxRecords)
);
}
Store current snapshots, unresolved messages, and only a small completion history. Never serialize complete Room, Creep, or structure objects, and do not place external authentication secrets in this synchronization layer.
Build the complete publication loop
function runInterShardSync(
remoteShards
) {
const localStatus = {
shard: Game.shard.name,
tick: Game.time,
ownedRooms: Object.values(Game.rooms)
.filter(room => room.controller?.my)
.map(room => room.name)
.sort()
};
const publication = publishLocalChannel(
'empireStatus',
localStatus
);
const observations = [];
for (const remoteShard of remoteShards) {
observations.push(
observeRemoteChannel(
remoteShard,
'empireStatus',
100
)
);
}
return {
publication,
observations
};
}
This loop publishes the current shard's owned-room names and observes the same channel from configured remote shards. It intentionally makes no fixed propagation-delay claim.
Common failure modes
- Shared writable-object assumption: remote shard strings are read-only.
- Blind JSON.parse(): empty, corrupt, old-schema, and wrong-source states collapse together.
- Partial setLocal update: another channel disappears because the entire string was replaced.
- String length as byte length: non-ASCII payloads can exceed the real byte budget.
- Cross-shard tick subtraction: freshness depends on an undocumented clock assumption.
- Revision without writer epoch: a valid stream restart looks like permanent regression.
- setLocal means synchronized: a local call is confused with later remote observation.
- Unbounded message history: serialization work and payload size grow forever.
Evidence and production boundary
This guide is based on the current official InterShardMemory and Game.shard APIs. Repository tests syntax-check the examples and simulate UTF-8 byte counts, damaged inputs, schema and source validation, revisions, writer-epoch changes, regressions, local observation freshness, size rejection, and handoff identity checks.
Official-shard propagation delay, real portal traversal, cross-shard CPU cost, restricted-shard access behavior, and live multi-tick evidence remain Pending.
Official references: InterShardMemory, Game.shard, Global Objects and Memory, and the game loop.