Skill and total hiscore rows were only written at logout, so the playtime
tiebreak recorded when a player next logged out after a level-up - hours
late for anyone who kept playing. Stamp player.playtime per stat on each
level-up, persist it in the save (SAV_VERSION 9), and use the stamp when
writing hiscore rows. Total level uses the latest stat stamp. Saves from
before the stamps existed fall back to logout playtime.
Note: an engine older than this rejects v9 saves, so rolling back past this
commit locks out anyone who logged out on it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Times were shown only to the minute, which no longer separates players at
the top of competitive boards. Show seconds, zero-pad minutes/seconds under
the leading unit with tabular digits so right-aligned columns line up, and
put the exact tick count in a hover tooltip.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NearbyPlayer gains combatTargetType/combatTargetIndex/inCombat, populated only
while the player's combatCycle corroborates the faced entity. BotState carries
combatTargetTelemetryVersion: 1 so consumers can tell supporting clients apart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
WebKit drops a permessage-deflate socket (close 1006) when compressed and
uncompressed messages are mixed. The feed chose per message based on the last
tick's duration, so near the threshold Safari viewers disconnected every few
seconds.
Fixes#72
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
execute_code
- Cancellation now reaches inside the SDK via an AsyncLocalStorage scope:
timeout, MCP cancel, or a newer call rejects pending waits and blocks
further action packets, including those inside walkTo().
- One execution per bot: a new call supersedes the running one and starts
once it has unwound; disconnect_bot does the same.
- Cancelled user code parks on bot/sdk calls instead of throwing, so a
catch-all loop can't spin and starve the server.
- Console capture is routed per execution, so concurrent bots no longer
clobber each other's output; leftover logs go to stderr.
- Stale world state warns and still runs, so a stale client can be diagnosed.
- Credentials are redacted from logs, results and launch messages.
SDK
- bot.mineRock() waits for ore plus Mining XP; interactLoc docs note that
success can mean the action only started.
- walkTo() takes options.timeout (default 120s), refuses stale state, and
reports the real last position.
- runScript uses the same cancellation scope for timeouts and signals.
Docs trimmed of duplicated or stale execute_code text.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
execute_code dropped the console output and world state whenever the code
threw or timed out, returning only the error. That's when the agent most
needs to see it died, a dialog is open, or it got "I can't reach that" -
in runebench runs a bot was killed mid-timeout and the result just said
"timed out". Errors now return console output, the error with up to six
stack frames, and the world state, flagged isError.
The world state appended to every call (~560 tokens) now uses a brief mode
that folds untrained skills into one line and drops NPC indexes and
NPC/object action lists, cutting about 25% per call measured over 44.5k
runebench blocks. The CLI keeps the full output.
The code parameter's example no longer suggests `return sdk.getState()`,
which duplicated the appended dump.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow up kadeski’s fix in PR #73 with current-balance and inventory-capacity checks. Keep the original report and authorship credited in the vendor patch checklist. Exercise both reported exploit inputs in compiled-script tests, and build engine content for those tests in CI.
Clamp a deposit to the coins held as well as the coffer space, and ignore a withdraw count of 0 or less. Listed in server/PATCHES.md until LostCity ships its own fix.
A read-only /worldfeed websocket that streams the players and npcs in a
subscribed tile area every tick (positions, steps, anims, spotanims,
facing, hitmarks, chat, appearance), plus an optional online roster. It
drives the rs-world 3D viewer (github.com/MaxBittker/rs-world).
Off unless WORLD_FEED=true. WORLD_FEED_TOKEN requires ?token=, and
WORLD_FEED_MAX_CLIENTS caps connections (default 32). While no viewer is
subscribed the tick hook returns immediately. Hidden-visibility players
are never sent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The previous export came from a local server after test bots had opened
and closed doors; this one is a fresh prod world.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each record costs an RSA decode (~0.3 ms) on the tick thread. Cap it at
8 per tick so a fleet of old clients retrying after the upgrade cannot
eat into the tick; refusals past the cap are recorded on a later retry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After a revision upgrade, a lite runner from an old checkout fails login
with an opaque "Client out of date" and its scripts only report "No game
client is attached".
- Engine: on a revision mismatch, read the username from the login block
(laid out the same across revisions) and record it for 30 minutes; a
correct-revision login clears it. The management port serves it at
/outdated-client.
- Gateway: when an SDK connects to a bot with no live client, ask the
engine and answer sdk_error with the update steps. Old SDK checkouts
print gateway errors, so this reaches people who haven't pulled yet.
- Public /api/version. The lite runner checks it before loading the
cache and fails with OutdatedClientError (a LoginError, code 6) naming
the fix; the runner and swarm exit 3 instead of retrying.
- CLAUDE.md troubleshooting entry for the message.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On a fresh login the SDK can attach before the character design modal
arrives (a throttled background tab widens this), and the first talk to
the guide can come back "I can't reach that!". skipTutorial checked for
the modal once and failed after a single talk attempt with a timeout.
When the guide is nearby, wait briefly for the design modal and accept
it, retry the talk up to three times, and name the last game message if
it still fails. Off Tutorial Island it returns immediately as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
289 added Ape Atoll (rows 42-43), its dungeon (142-143) and squares at
raw row 69, which the old z remap either dropped or drew on top of the
underground. The bounds also skipped every edge square, so row 44 and
columns 55-56 were never drawn.
Replace the ad-hoc remap with a band table (overworld 42-62, underground
142-161 -> 63-82, misc 69-77 -> 83-91), widen the bounds to columns
29-56 with a padding square on each side, and leave rows outside every
band undrawn instead of placing them at their raw position.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Upstream pack ids jump at every revision bump, which is where ours were
appended, so each upgrade renumbered them. Move the two kinds that can
cheaply sit above any upstream revision (377-wip tops out at interface
18785, varp 724):
- GE interface components 11942-12437 -> 40000-40495. The interface
format lists ids explicitly, so the gap costs nothing.
- pvp_death_mark varp 394 -> 1000. Saves store varps by id; a fixed id
ends save migrations. The pre-289 358 migration targets it by name.
The GE booth loc and teller npc stay tail-appended: locs allocate an
engine object per id, and npc types are 11 bits in 289's NPC_INFO.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The GE root component moved to 11942 in the 289 content packs.
collision-data.json is re-exported from a 289 members build: 136.7k zones
(was 123.6k), 1.27M tiles, 2658 doors.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rebases the vendored engine, content and webclient onto upstream's 289
branches (34 engine, 165 content, 9 webclient commits past our 274 base).
Each vendor-289 branch in ../repos is upstream 289 plus one squashed
"rs-sdk local mods" commit, refreshed from this tree first.
289 adaptations:
- Protocol: 289 reshuffles every opcode. Custom MESSAGE_PUBLIC (server 255)
and MARKET_SEARCH (client 243) are still free and kept.
- Lite client: CLIENT_VERSION 289, UPDATE_INV_FULL size g1->g2 and
UPDATE_INV_PARTIAL slot g1->gsmart, mirroring Client.ts.
- Content ids: upstream appended configs where ours lived. GE interface
moves 10984->11942 (through 12437), grand_exchange_booth 4671->5116,
grand_exchange_teller 1359->1596 (m49_53.jm2 updated), pvp_death_mark
varp 358->394. PlayerLoading moves a saved varp 358 value to
pvp_death_mark (289's varp 358 is temp-scoped, so only pre-289 saves
carry it).
- GE loc/npc configs use 289's renamed model debugnames.
- Engine: keep our Bun web split over upstream's new Fastify web.ts (no
fastify/tsx deps), add OnDemand.onClientClosed to our WS close handler,
keep our WSClientSocket API/batching and the 1s delayed close (TCP too).
Adopt upstream's HashTable iterator fix, IPv6 player-loop key (with our
NaN guard), chat effects 0-5, pointer-check removal.
- Razmire building store keeps our 650 sell price with upstream's 4% haggle.
- Tests: opcode assertions use ClientProt enums; hand-built inv packets
use the 289 layout.
PATCHES.md documents the new per-upgrade steps (custom id renumbering,
lite decoder parity, opcode collision check).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- item-census.ts / item-census-html.ts: count and holders per item across
every save plus GE escrow, rendered as a sprite viewer
- find-item-owners.ts: who holds a given obj id
- grant-bank-item.ts: patch an offline player's bank, optionally waiting
for them to log out
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Matches the deployed config: 120s graceful stop, and HISCORES_HIDDEN_NAMES
covering the imported local test saves.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
World.processLogins() swapped in the replacement socket without pointing it
back at the active player, and the WS/TCP close handlers cleared a player's
socket without checking that the closing socket still owned it. A delayed
close of the old socket could detach the replacement.
Same change as 87dc1bf18 (deployed 2026-10-06), applied to main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Skill and overall rows rewrote playtime on every logout, even with no XP
gained. Playtime is the tiebreak (level desc, playtime asc), so maxed
players slid down the board for continuing to play. Now playtime records
when the current level was reached; XP-only changes still update value.
Matches the quest points board.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Prod only has the "main" profile, so the selector on the list and player
pages offered one choice and did nothing. ?profile= still works.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tools/server/backfill-quest-points.ts fills hiscore type 100 for players
who haven't saved since the board shipped. Older saves don't hold %qp (it
was temp-scoped), so the total is recounted from the saved quest progress
varps by evaluating [proc,count_questpoints] from content; any condition
form it doesn't understand is a hard error.
Only reads saves for accounts with >= 1h playtime or >= 100 total level
(every quest holder seen at launch had >= 7h and >= 126), skips staff and
banned accounts, and never replaces an existing row. --verify checks the
recount against %qp in saves written since it became perm.
Ran on prod 2026-10-07: verify 643/643 matched; 15,896 candidates, 2,716
rows filled.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/hiscores?category=100 ranks players by quest points, ties broken by the
playtime they reached that total at. Linked from the hiscores sidebar and
shown as a row on player profiles. Stored in the hiscore table under type
100, so no migration.
The qp varp was temp-scoped, so it never reached the save file the login
server reads. Make it scope=perm (the login trigger still recounts it from
quest progress every login), and load VarPlayerType in the login server so
saves load with their varps. The login server warns at startup if qp ever
stops being perm.
Updated on logout and autosave; autosave only writes when the total changes.
Existing players appear after their first save on this build.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Following the README, `bun bots/<name>/script.ts` with no browser tab or lite
runner attached printed "Connected", ran every action against nothing, and
exited 0. runScript never launches a client (autoLaunchBrowser: false), and
the README said the SDK would.
There are two ways that happened:
- No client ever attached: the gateway has no state, connect() times out its
ready wait and then "continues anyway".
- A client attached earlier but has gone: the gateway replays the state it
cached, so the ready check passes on a snapshot minutes old.
runScript now waits up to 10s for `tick` to advance after connecting. It
compares ticks, not getStateAge(), because that mixes gateway and local
clocks; a live run measured -343ms. If no tick arrives it throws an error
that names both ways to start a client, and a failed connect now sets
exitCode 1.
The README Getting Started and CLAUDE.md now include the lite runner step,
the README no longer claims scripts launch a client, and the CLAUDE.md
troubleshooting entry no longer points at an option runScript doesn't expose.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The browser bot attached a text rendering of the world state
(formatWorldStateForAgent) to every state message. Nothing reads it: the
gateway forwards only message.state, and the SDK, MCP server and lite runner
never refer to it. Dropping it trims ~5% of each browser bot frame (~10%
after compression).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Older SDKs and browser bots still reach the gateway through the game
server's /gateway relay, uncompressed, which was ~85% of rs-sdk-demo's
bandwidth. The engine's web server now negotiates permessage-deflate and
sends relayed frames compressed. Clients that offer the extension (browsers,
Bun, Node ws) pick it up with no change on their side and compress what they
publish as well. Clients that don't keep working uncompressed.
Bun can't opt out per upgrade, so game sockets negotiate it too, but game
packets still go out uncompressed (ws.send's default), and small client
packets showed no inflate cost.
Compression runs on the tick thread at ~7 ms CPU per MB, so a frame is sent
uncompressed whenever the last tick used 75% or more of its budget.
Real state frames shrink to ~14% in both directions. On prod, eth0 went
from ~100 Mbps out / ~120 in to ~21 out / ~18 in.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
GET /traffic (bytes by swarm, connection mode, Fly edge and source IP, with
a $/month estimate) and GET /traffic/fields (per-field share of state bytes
and how often each repeats frame to frame, sampled 1 in 30). Both answer
only on the internal port 7780, never on the public listener.
The engine's /gateway relay forwards Fly-Region and Fly-Client-IP as
?edge=&ip= because the gateway can't see those headers through the
loopback hop.
This is what has been running on rs-sdk-demo since v181.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MAX_PLAYERS_PER_IP is now 100 on Fly (code default stays 200 for local load
tests). getIp only trusts Fly-Client-IP behind Fly and the socket peer
elsewhere; the old cf-connecting-ip / X-Forwarded-For lookup took the first,
client-supplied entry, so a swarm could claim a new IP per connection and walk
past the cap.
The gateway no longer fans out a state frame that repeats the last one sent
to subscribers apart from its publication revision (same tick, same content:
a post-action publish where the action changed nothing visible yet).
Subscribers get the next tick's frame instead; any difference still goes out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The gateway can also listen on GATEWAY_PUBLIC_PORT (on Fly:
wss://rs-sdk-demo.fly.dev:7443 -> 7781) with permessage-deflate. On live
bot state frames that is ~16% of the uncompressed bytes at ~6 ms CPU per MB,
spent in the gateway process rather than on the game server's tick thread.
The main port is unchanged and uncompressed, so the engine's /gateway relay
and every existing SDK and runner behave exactly as before.
SDK connect() and the lite runner check the direct port (3 s) and use it when
it answers, otherwise they keep wss://.../gateway; a failed connect checks
again. GATEWAY_DIRECT=false opts out. HTTP endpoints keep the relayed origin.
GATEWAY_HOSTNAME lets a fleet run a private loopback gateway (sdk/README.md).
sdk/chat.ts and the MCP server now honor a bot.env GATEWAY_URL, as cli.ts and
runScript already did. The Dockerfile sed that forced 0.0.0.0 is replaced by
that option's default.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
execute_code collected console output in an unbounded array. A script whose
catch block logs and loops without awaiting (e.g. retrying a method that
throws synchronously) never yields, so the execution timeout can't fire and
the array grows until the process runs out of memory. Two RuneBench trials
(Sonnet 5.5, hitpoints) most likely lost their sandbox this way looping on the
removed bot.attackNpc; a surviving run logged the same error 16,210 times.
ConsoleCapture keeps the first 300 and last 300 lines, clips lines at 2000
chars, and reports how many lines were omitted, so memory stays constant.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
/hiscores/runite now renders the three windows as columns on one page
instead of tabs behind ?window=. Each board lists the top 10 by ore mined
(rank, name, ore); the "Last mined" column is dropped.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>