Re-read the current owned order before changing it
Game.market.orders contains your current active and inactive buy and sell orders. Do not save an order object in Memory and treat it as authoritative on a later tick. Save the order ID, then read the current order again immediately before any maintenance request.
function copyOrderSnapshot(order) {
if (!order) return null;
return {
id: order.id,
created: order.created ?? null,
active: order.active,
type: order.type,
resourceType: order.resourceType,
roomName: order.roomName ?? null,
amount: order.amount,
remainingAmount: order.remainingAmount,
totalAmount: order.totalAmount,
price: order.price
};
}
The three amount fields answer different questions. totalAmount is the order's total capacity after creation and extensions. remainingAmount is capacity that has not yet been traded. amount is the amount currently available to trade and can differ from remainingAmount when resources, Credits, or Terminal state limit activity.
Use stable identity fields for operator safety, but do not fingerprint volatile fields such as active, amount, or remainingAmount. A legitimate market change can alter them between planning and execution.
Do not require roomName for every order
The current official market documentation makes roomName optional for account-bound resources in INTERSHARD_RESOURCES; that argument is not used for those orders. A validation rule that requires every expected room to be a string will therefore reject valid account-bound orders.
Normalize missing room identity to null. For a normal Terminal-backed order, configure the exact room name. For an account-bound order with no room, configure null.
expected: {
type: ORDER_SELL,
resourceType: RESOURCE_UTRIUM,
roomName: 'W1N1',
price: 1,
totalAmount: 10000
}
// Account-bound example shape:
expected: {
type: ORDER_SELL,
resourceType: 'pixel',
roomName: null,
price: 2500,
totalAmount: 10
}
The literal resource and prices above are configuration examples, not claims about your current shard or market.
Separate the official fee formula, your reserve estimate, and actual settlement
The current official API documents these maintenance fees:
- Raise an order price:
(newPrice - oldPrice) * remainingAmount * 0.05. - Lower an order price: no additional price-change fee.
- Extend an order:
price * addAmount * 0.05. - Cancel an order: the original 5% order fee is not refunded.
The checked 4.3.2 processor calculates a fee in its internal money representation with Math.ceil(...) and checks the user's money again while processing the global intent. Public Game.market.credits is exposed in Credits, while the current engine stores money in thousandths internally. For reserve planning, this article deliberately rounds a positive formula estimate up to the next 0.001 Credit.
const MARKET_FEE_RATE = 0.05;
function ceilToMilliCredit(value) {
if (!Number.isFinite(value) || value <= 0) return 0;
return Math.ceil(value * 1000 - 1e-9) / 1000;
}
function estimatePriceChangeFee(order, newPrice) {
const formula = Math.max(0, newPrice - order.price)
* order.remainingAmount
* MARKET_FEE_RATE;
return ceilToMilliCredit(formula);
}
function estimateExtensionFee(order, addAmount) {
return ceilToMilliCredit(
order.price * addAmount * MARKET_FEE_RATE
);
}
Important: this is a conservative project-side reserve estimate, not a billing receipt. The runtime API does an initial Credits check when you call it, but the global processor checks money again. Another fee-bearing market intent submitted in the same tick can consume Credits before a later intent is processed. That means OK is still only “scheduled successfully,” not proof that the mutation settled.
To make your own automation predictable, serialize fee-bearing market maintenance through one writer. A Credits reserve protects your policy only against requests that this writer knows about; it cannot reserve Credits against unrelated code that also submits market intents.
Use one writer and one request ID
A safe maintenance module should fail closed when a request is ambiguous or duplicated:
- one explicit action: change price, extend, or cancel;
- one exact owned order ID;
- one expected fingerprint with nullable room identity;
- one unique
requestIdthat cannot be executed twice; - one pending accepted request at a time;
- one Credits reserve check using a conservative fee estimate.
Do not automatically retry an accepted request because its effect was not observed immediately. A retry can duplicate an extension or race another writer. Resolve the evidence gap first, then create a new request ID only if you intentionally want another mutation.
Complete one-shot maintenance controller
The example below accepts a preconfigured request in Memory. It verifies any previous accepted request first, refuses duplicate request IDs, re-reads the owned order, checks the fingerprint, calculates a conservative reserve estimate, disables the request before the API call, preserves the raw return code, and records the next-tick order state.
const MARKET_ACTIONS = new Set([
'change-price',
'extend-order',
'cancel-order'
]);
const HISTORY_LIMIT = 30;
const REQUEST_ID_LIMIT = 50;
const NUMBER_EPSILON = 1e-9;
const MARKET_FEE_RATE = 0.05;
function sameNumber(left, right) {
return Number.isFinite(left)
&& Number.isFinite(right)
&& Math.abs(left - right) <= NUMBER_EPSILON;
}
function ceilToMilliCredit(value) {
if (!Number.isFinite(value) || value <= 0) return 0;
return Math.ceil(value * 1000 - 1e-9) / 1000;
}
function getMaintenanceMemory() {
Memory.market ??= {};
Memory.market.orderMaintenance ??= {
request: null,
pending: null,
history: [],
attemptedRequestIds: []
};
return Memory.market.orderMaintenance;
}
function copyOrderSnapshot(order) {
if (!order) return null;
return {
id: order.id,
created: order.created ?? null,
active: order.active,
type: order.type,
resourceType: order.resourceType,
roomName: order.roomName ?? null,
amount: order.amount,
remainingAmount: order.remainingAmount,
totalAmount: order.totalAmount,
price: order.price
};
}
function rememberAttemptedRequestId(memory, requestId) {
memory.attemptedRequestIds.push(requestId);
memory.attemptedRequestIds = memory.attemptedRequestIds
.slice(-REQUEST_ID_LIMIT);
}
function wasAttempted(memory, requestId) {
return memory.attemptedRequestIds.includes(requestId);
}
function validateRequest(request) {
if (!request || request.enabled !== true) {
return { ok: false, reason: 'request-disabled' };
}
if (
typeof request.requestId !== 'string'
|| request.requestId.length === 0
) return { ok: false, reason: 'invalid-request-id' };
if (!MARKET_ACTIONS.has(request.action)) {
return { ok: false, reason: 'invalid-action' };
}
if (
typeof request.orderId !== 'string'
|| request.orderId.length === 0
) return { ok: false, reason: 'invalid-order-id' };
const expected = request.expected;
if (
!expected
|| typeof expected !== 'object'
|| typeof expected.type !== 'string'
|| typeof expected.resourceType !== 'string'
|| !(
expected.roomName === null
|| typeof expected.roomName === 'string'
)
|| !Number.isFinite(expected.price)
|| expected.price <= 0
|| !Number.isFinite(expected.totalAmount)
|| expected.totalAmount <= 0
) return { ok: false, reason: 'invalid-order-fingerprint' };
if (
!Number.isFinite(request.reserveCredits)
|| request.reserveCredits < 0
) return { ok: false, reason: 'invalid-credit-reserve' };
if (
request.action === 'change-price'
&& (!Number.isFinite(request.newPrice) || request.newPrice <= 0)
) return { ok: false, reason: 'invalid-new-price' };
if (
request.action === 'extend-order'
&& (!Number.isInteger(request.addAmount) || request.addAmount <= 0)
) return { ok: false, reason: 'invalid-add-amount' };
if (
request.action === 'cancel-order'
&& request.confirmCancel !== true
) return { ok: false, reason: 'cancel-not-confirmed' };
return { ok: true, reason: null };
}
function matchesExpectedOrder(order, expected) {
return order.type === expected.type
&& order.resourceType === expected.resourceType
&& (order.roomName ?? null) === expected.roomName
&& sameNumber(order.price, expected.price)
&& order.totalAmount === expected.totalAmount;
}
function estimateFee(order, request) {
if (request.action === 'change-price') {
return ceilToMilliCredit(
Math.max(0, request.newPrice - order.price)
* order.remainingAmount
* MARKET_FEE_RATE
);
}
if (request.action === 'extend-order') {
return ceilToMilliCredit(
order.price * request.addAmount * MARKET_FEE_RATE
);
}
return 0;
}
function finishRequest(request, status, detail = {}) {
request.enabled = false;
request.status = status;
request.finishedAt = Game.time;
Object.assign(request, detail);
}
function verifyPending() {
const memory = getMaintenanceMemory();
const pending = memory.pending;
if (!pending || pending.tick >= Game.time) return null;
const order = Game.market.orders[pending.orderId] ?? null;
const after = copyOrderSnapshot(order);
let status = 'mutation-not-observed';
if (pending.action === 'change-price') {
status = order && sameNumber(order.price, pending.newPrice)
? 'requested-price-observed'
: order
? 'requested-price-not-observed'
: 'order-unavailable-after-price-request';
}
if (pending.action === 'extend-order') {
const expectedTotal = pending.before.totalAmount
+ pending.addAmount;
status = order && order.totalAmount >= expectedTotal
? 'total-capacity-increase-observed'
: order
? 'extension-not-observed'
: 'order-unavailable-after-extension';
}
if (pending.action === 'cancel-order') {
status = order
? 'cancel-not-observed'
: 'order-absence-observed';
}
const record = {
verifiedAt: Game.time,
...pending,
after,
status,
creditsObservedAfter: Game.market.credits,
creditsDelta: Game.market.credits - pending.creditsBefore,
priceDelta: after
? after.price - pending.before.price
: null,
remainingAmountDelta: after
? after.remainingAmount - pending.before.remainingAmount
: null,
totalAmountDelta: after
? after.totalAmount - pending.before.totalAmount
: null
};
memory.history.push(record);
memory.history = memory.history.slice(-HISTORY_LIMIT);
memory.pending = null;
return record;
}
function processMarketOrderMaintenance() {
const memory = getMaintenanceMemory();
const verification = verifyPending();
// One accepted mutation is verified before another is submitted.
if (memory.pending) {
return { status: 'awaiting-next-tick-verification' };
}
const request = memory.request;
if (!request || request.enabled !== true) {
return { status: 'no-enabled-request', verification };
}
const validation = validateRequest(request);
request.attemptedAt = Game.time;
if (!validation.ok) {
finishRequest(request, validation.reason);
return { status: validation.reason, verification };
}
if (wasAttempted(memory, request.requestId)) {
finishRequest(request, 'duplicate-request-id');
return { status: 'duplicate-request-id', verification };
}
const order = Game.market.orders[request.orderId];
if (!order) {
finishRequest(request, 'owned-order-not-found');
return { status: 'owned-order-not-found', verification };
}
if (!matchesExpectedOrder(order, request.expected)) {
finishRequest(request, 'order-fingerprint-mismatch', {
observedOrder: copyOrderSnapshot(order)
});
return { status: 'order-fingerprint-mismatch', verification };
}
if (
request.action === 'change-price'
&& sameNumber(order.price, request.newPrice)
) {
finishRequest(request, 'price-already-matches');
return { status: 'price-already-matches', verification };
}
const estimatedFee = estimateFee(order, request);
const creditsBefore = Game.market.credits;
if (creditsBefore - estimatedFee < request.reserveCredits) {
finishRequest(request, 'credit-reserve-would-be-crossed', {
estimatedFee,
creditsBefore
});
return {
status: 'credit-reserve-would-be-crossed',
estimatedFee,
verification
};
}
const before = copyOrderSnapshot(order);
// Idempotency boundary: once we reach an API attempt, this ID is spent.
rememberAttemptedRequestId(memory, request.requestId);
request.enabled = false;
request.estimatedFee = estimatedFee;
request.creditsBefore = creditsBefore;
request.before = before;
let result = ERR_INVALID_ARGS;
if (request.action === 'change-price') {
result = Game.market.changeOrderPrice(
request.orderId,
request.newPrice
);
} else if (request.action === 'extend-order') {
result = Game.market.extendOrder(
request.orderId,
request.addAmount
);
} else if (request.action === 'cancel-order') {
result = Game.market.cancelOrder(request.orderId);
}
request.result = result;
request.finishedAt = Game.time;
request.status = result === OK
? 'request-scheduled'
: 'api-rejected';
if (result === OK) {
memory.pending = {
tick: Game.time,
requestId: request.requestId,
action: request.action,
orderId: request.orderId,
before,
estimatedFee,
creditsBefore,
newPrice: request.action === 'change-price'
? request.newPrice
: null,
addAmount: request.action === 'extend-order'
? request.addAmount
: null
};
}
return {
status: request.status,
action: request.action,
orderId: request.orderId,
result,
estimatedFee,
creditsBefore,
before,
verification
};
}
module.exports.loop = function () {
const outcome = processMarketOrderMaintenance();
if (
outcome.status !== 'no-enabled-request'
|| outcome.verification
) {
console.log(JSON.stringify({
type: 'market-order-maintenance',
tick: Game.time,
...outcome
}));
}
};
What next-tick verification can—and cannot—prove
The official API describes OK as “scheduled successfully.” The checked engine also revalidates ownership/arguments and, for fee-bearing operations, available money while processing global intents. Therefore the accepted return code and the later order state are two distinct pieces of evidence.
| Action | Next-tick observation | Evidence limit |
|---|---|---|
| Change price | Current order exists and its price matches the requested price. | Strong state evidence; unique causality still assumes this module is the only writer changing that order. |
| Extend order | totalAmount is at least the previous total plus addAmount. | Do not require an exact remainingAmount delta because deals can reduce remaining capacity. |
| Cancel order | The order is absent from Game.market.orders. | Strong state evidence under a single-writer policy; absence alone does not identify which writer caused it. |
Current market-maintenance methods do not produce a Room event-log record equivalent to a Creep transfer(). The public script therefore cannot use Room.getEventLog() as an exact maintenance receipt.
creditsDelta is also not an exact fee receipt when other deals, sales, purchases, order fees, or market maintenance can change Credits during the same observation window. Preserve it as context. Do not claim that -creditsDelta === estimatedFee unless you control and document the whole market-write window.
One current-engine detail explains why an extension check should focus on totalAmount: the global market processor applies order extensions before processing deals, and a later deal can still reduce remainingAmount. A price change that actually changes the price is marked to skip deals on that order in the same processor pass. These are checked engine behaviors, not generic promises for every future engine version.
Return-code and source mismatch checklist
| Method | Current official documentation | Checked 4.3.2 runtime wrapper |
|---|---|---|
changeOrderPrice() | OK, ERR_NOT_OWNER, ERR_NOT_ENOUGH_RESOURCES, ERR_INVALID_ARGS | The checked wrapper directly returns OK, ERR_NOT_ENOUGH_RESOURCES, or ERR_INVALID_ARGS for this call path; it resolves the order from your own Game.market.orders. |
extendOrder() | OK, ERR_NOT_ENOUGH_RESOURCES, ERR_INVALID_ARGS | Matches those direct wrapper checks. |
cancelOrder() | OK, ERR_INVALID_ARGS | Matches those direct wrapper checks. |
The documentation/engine difference for changeOrderPrice() is recorded rather than silently “fixed” in favor of one source. Preserve the raw code your account actually returns. If you observe ERR_NOT_OWNER on the official shard, that live observation is stronger operational evidence than this static source comparison and should be recorded with the exact tick and order type.
None of these methods uses Creep range, so ERR_NOT_IN_RANGE does not belong in this maintenance workflow.
Evidence and engine boundaries
The current official Game.market.orders, changeOrderPrice(), extendOrder(), cancelOrder(), and order-room rules were rechecked on August 18, 2026. Current screeps/engine master was also checked at 4.3.2, commit 80977824199a596d174d392fd0cf8c458c21fcbd.
Fee boundary: 5% formulas are documented API rules. Rounding to the next 0.001 Credit in this article is a conservative project reserve policy motivated by the current processor's internal Math.ceil; it is not presented as a universal billing API.
Concurrency boundary: the runtime wrapper can return OK before the global processor applies the mutation. Multiple same-tick fee-bearing market intents can compete for the same Credits balance, and unrelated market activity can make Credits deltas ambiguous.
Writer boundary: next-tick state proves the observed order state. Attribution to this request is strongest only when one module owns writes to that order and request IDs are not reused.
Live evidence: Screeps Console test: Pending. Live account-bound order maintenance: Pending. Same-tick multi-fee contention: Pending. Live official-shard return-code mismatch check: Pending. No live result is fabricated.
Continue with creating market orders, executing deals, or Terminal transfers.