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>
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>
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.
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>
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>
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>
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>
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>
/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>
Outfit rows only gained an appearance on the player's next save, and most
of the board's top players are long offline, so the sprites never showed.
tools/server/backfill-outfit-appearance.ts rebuilds it once from each save
(via the exchange checkpoint when present, like the login server), filling
only still-empty rows whose listed items match the save.
outfitAppearance() moves to its own module so the tool can share it with
the login server.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rip out KOTH (Koth.ts, the per-minute World hook, koth_capture logging and
table, /hiscores/koth) for simplicity. In its place, the mining script calls
a new runite_mined(coord) command whenever it gives runite ore; the engine
counts only the two Lava Maze rocks (0_3059_3885, 0_3060_3884) and relays a
runite_mine row through the logger pipeline. /hiscores/runite ranks ore
mined (all time / week / today) under an in-game shot of the rocks. No
backfill. Migration 20260926000000_runite_mine drops koth_capture.
The server-side player sprites move to the Equipment board: the login
server stores each outfit's resolved appearance slots in the new
hiscore_outfit.appearance column, the top 10 rows show /sprite/player
images, and item icons use /sprite/item instead of the in-browser viewer.
Rows without an appearance get one on the player's next save.
The sidebar link list shared by the hiscores pages is now one helper.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bank/outfit hiscores were only written on logout, so players who stay
online for days (e.g. #2 on the bank board) showed values days stale.
Autosave now updates both boards after a successful save write. Rows are
only rewritten when value/items changed, since autosave posts every
online player in one burst. Skill hiscores stay logout-only.
Co-Authored-By: Claude Code <noreply@anthropic.com>
- New "Sell orders" tab / sort=sellRecent: items with open asks, newest
listing first, showing ask, sell qty, sell value and last listed time.
- MarketOverview and item quotes now include sellValue and lastSellAt.
- SDK MARKET_SORTS (and so the get_market MCP tool) accept sellRecent.
- Market list and API default to days=7 instead of 24h.
Co-Authored-By: Claude Code <noreply@anthropic.com>
The segment query filtered and sorted on end_time, which has no index, so
every request full-scanned 13M segments in the 32GB db. That took longer
than Bun's 10s idle timeout, and the fly proxy answered with a bare 502.
- bound start_time (segments span <1h) so the query uses its index
- stride-sample segments across the window past 100k instead of taking
only the newest 250k (a week is now 2.2M segments), and skip raw rows
when sampling
- dedupe concurrent builds per window, serve stale cache while rebuilding,
and snap ?hours= to fixed buckets so the cache stays bounded
- extend the proxy request timeout and bound the upstream fetch
Co-Authored-By: Claude Code <noreply@anthropic.com>
KILL_LEDGER_FILE records every NPC death at the NPC_DEL chokepoint with the
credited killer and full damage split. TRADE_LEDGER_FILE records completed
player trades at the moment the exchange executes. Both are off by default.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Hiscores pages emit <img> tags pointing at /sprite/player/... and
/sprite/item/... URLs; sprites are rendered once in a worker thread hosting
the webclient's ItemViewer, PNG-encoded, LRU-cached and served with long
cache headers, instead of every browser pulling the whole model cache.
- KOTH page shows the reigning king of each window (all time / week / today)
side by side above the table.
- ItemViewer gains a lazy ZipProvider that unpacks models on first use.
- Viewer cache archives get Cache-Control headers (immutable when crc-suffixed).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Engine
- World.processLogins takeover: closeModal() before save(), so items parked in
temp invs (tradeoffer/dueloffer/duelwinnings/partyroom_tempinv) return to the
pack before the snapshot. Logging in from a second client mid-trade deleted
the offer and left the partner's screen stuck; reproduced locally before and
after.
- Web server proxies /state/:bot, /chat/:bot, POST /say/:bot, POST /dm/:bot to
the gateway (cli.ts / chat.ts HTTP fast paths 404'd on deployments that
front the gateway through the engine web port).
SDK
- interactNpc/useItemOnNpc/talk path re-find an entity target by server index
after walking (ActionHelpers.refindNpc) instead of by name, so a chosen
fishing spot is not swapped for the nearest same-named one.
- runner: log a failed initial connect and tear down the orphan BotSDK (its
reconnect timers kept the process alive as a zombie); connect race 30s -> 90s
to cover a cold browser launch; browserLaunchTimeout default 30000 as documented.
- mcp/api: bot.env repo-root fallback was one directory short.
- cli.ts honours GATEWAY_URL from bot.env like the runner does.
- shortestNameMatch falls back to a substring match when a literal name with
regex metacharacters ("Serum 207 (1)") matches nothing as a regex (+test).
- eatFood accepts Drink; openBank skips "Closed bank booth" and prefers a booth
with a Bank/Use op; attack re-finds a bare {index} handle instead of throwing;
depositItem(number) returns an invalid-target result instead of a TypeError.
- ActionDispatchError sets its name via defineProperty (hosts that freeze
Error.prototype made the assignment throw from ws.onclose).
Webclient
- Client.say() routes '::' to CLIENT_CHEAT like the keyboard path and the lite
client, instead of speaking cheats aloud as public chat.
- Client.isBankOpen() returns false when no main modal is open (component 2006
linkObjType is never cleared, so state.bank.isOpen stuck true all session).
- acceptCharacterDesign (lite + browser) prefers the CC_ACCEPT_DESIGN button
inside the open modal; other kits carry the same clientcode with no trigger.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- /api/exportCollision now caches the serialized export after the first
request (the full scan runs on the tick thread and took 2-11s per hit)
and rejects non-GET methods. Restart invalidates, matching how map
content actually changes. Fixes#60.
- /bot no longer defaults the password to "test" (or "botpass123" in
createAndLogin); a blank field generates a random 12-char password
shown in the field and persisted via the existing sync. Fixes#57.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The engine already supported MESSAGE_PRIVATE end-to-end; this adds the
send path and tooling:
- sendPrivateMessage on browser Client + LiteClient (userhash + WordPack
payload, local type-6 echo), new 'privateMessage' bot action
- sdk.sendPrivateMessage(name, msg) + sdk.dm(name, text) (chunked like say)
- gateway: observe mode whitelists privateMessage alongside say; new
POST /dm/:username endpoint - a separate path from /say on purpose, so
old gateways' non-JSON banner makes the CLI fall back to the WS path
(cleanly rejected) instead of leaking a DM as public chat
- chat CLI: bun sdk/chat.ts <bot> --to <player> "msg"
- engine: FriendServer PM handler logs private_chat best-effort instead of
letting a missing account row (local easy-startup) kill the relay
PMs deliver to any online player - no friends list needed. One social
packet (say or PM) per tick; dm() paces its chunks. Delivery requires
FRIEND_SERVER=true (prod fly.toml already sets it; local quickstart
defaults off and drops PMs silently).
Verified E2E on the local stack: both directions, 3-chunk long message,
observe-mode WS path, non-chat actions still rejected in observe mode.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mid-tick packets (script replies, dialogs, login/logout) go out immediately
again; the per-tick coalescing now only covers the client-out phase, which is
where ~all the packets come from. Out-of-order is impossible: an immediate send
flushes anything already batched for that socket first.
Co-Authored-By: Claude <noreply@anthropic.com>
Profiling prod (~950 players) put ~20% of the tick thread in ws.send(): every
packet was its own websocket frame + syscall (~20 per player per tick). Batch
per client in WSClientSocket and flush once at the end of processClientsOut;
login-phase replies still go out immediately, close() flushes first, and the ws
close event discards the batch. Clients read the socket as a byte stream, so
framing is not observable.
Also collapse /status/:bot in the web request stats.
Co-Authored-By: Claude <noreply@anthropic.com>
- management port: /tickstats (rolling per-phase cycle timings + per-route web
request stats), /profile (JSC sampling profiler over the main thread),
/prometheus computed per request instead of frozen at startup
- web: log requests slower than WEB_SLOW_MS (100) with client ip; per-route
counters in /tickstats
- hiscores: ranked lists cached 60s (HISCORES_CACHE_MS) instead of materialising
the whole hiscore/hiscore_large table on the tick thread per request; player
page ranks come from the cached lists instead of 22 count() scans
- login: refuse session takeovers within LOGIN_TAKEOVER_MIN_MS (10s) of the
previous one (response 5) - crash-looping clients were doing ~3 takeovers/s
- lite/loadtest.ts: N idle lite sessions for local load testing
Co-Authored-By: Claude <noreply@anthropic.com>
Prod login outage 2026-08-17 after the v159 redeploy: reconnect stampede saturated
the login worker with pure-JS bcrypt-ts compares (~5/s at 100-200ms each), every
sdk_auth crossed the gateway's 15s timeout, clients retried, backlog never drained.
- comparePassword: remember (stored hash, sha256(plaintext)) outcomes so repeat
logins skip bcrypt; verify cold via Bun.password (native, thread pool)
- gateway stamps sentAt on sdk_auth; login server skips requests older than 14s
Co-Authored-By: Claude <noreply@anthropic.com>
A partial write (ENOSPC during the disk-full event) left a record cut
mid-line, and the next append spliced its JSON into the same line,
permanently breaking JSONL parsing for both. Before appending, check the
log's last byte and start on a fresh line if the previous append was cut
short.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MAX_PLAYERS_PER_IP still overrides; this only lowers the default memory
backstop introduced after the 2026-07-21 swarm OOM.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The filter runs in two independent layers, both now gated:
- Engine: WordEnc.filter skips censoring (bad words, domains, TLDs,
fragments) when NODE_PROFANITY_FILTER=false, keeping casing
normalization. Covers public chat ingest and PM encode.
- Browser client: the setting is injected via bot.ejs/client.ejs into
the Client constructor and sets WordFilter.enabled, so the local
re-filter follows the server automatically.
- Lite client: new SessionOptions.profanityFilter; the runner reads
PROFANITY_FILTER from bot.env / process env (no server config
channel exists headless).
Default unchanged (filter on).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Gateway: observe-mode sessions may send 'say' (and nothing else), so
chat tools can talk through a bot without pre-empting its controller
- sdk/chat.ts: send / read / --watch chat over an observe connection
- maxMessageLength default 80 -> 400; engine WordPack caps follow the
config instead of hardcoding 80
- 240-byte packed budget in both WordPack copies + byte-aware SDK
chunking: all chat frames carry a 1-byte length and digit/punctuation
chars pack at ~1 byte each, so long non-prose text would wrap the
length byte and corrupt the stream
- chat handlers use Packet.alloc(1): the 100-byte alloc(0) tier threw
RangeError on >~200-char messages and kicked the player with no log
- say() paces multi-chunk sends with wall-clock sleep in observe mode
(a 'wait' action is rejected there and would occupy the bot executor)
- lite runner: GATEWAY_URL override (SERVER doubles as game origin and
gateway address, which broke for localhost:8888)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Post-deploy reconnect storms starved logins for minutes: every worker
thread opens its own connection to db.sqlite in rollback-journal mode
with no busy timeout, so the telemetry compactor's first run (2min after
boot, exactly mid-storm) blocked login queries into SQLITE_BUSY and
players saw "login server offline".
- db/query.ts: journal_mode=WAL + synchronous=NORMAL + busy_timeout=10s
on every sqlite connection - reads no longer block on writes, and
contending writers wait instead of erroring
- LoggerServer: first compaction 2min -> 15min after boot (env
overridable), 10ms pause between per-group write transactions so other
threads get lock windows mid-pass
- gateway: auth timeout 10s -> 15s so it outlasts the busy_timeout
Verified: reads complete in 1ms against an open write transaction
(previously "database is locked").
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every wall-clock minute, the highest combat level player standing inside
the Demonic Ruins wall polygon (traced from m51_60.jm2, incl. the western
annex; interior wall faces only) scores a point and is crowned; the
current king wins ties, an empty hill dethrones. Captures append to a new
koth_capture table via the logger pipeline, snapshotting the winner's
resolved appearance slots. /hiscores/koth shows all-time/week/day boards
with three kings side by side and median-loadout character sprites,
rendered in-browser by the viewer's new renderPlayerSpriteAsImageData
(ClientPlayer model assembly + offscreen Pix2D raster). Crown messages
are local (3 zones), never server-wide.
Also: split LoopCycle out of Client so the viewer bundle doesn't drag the
game client in, and fix the worker-killing once('error') ws handler in
InternalClient (the "engine dies ~40s into a bot session" local crash),
with per-worker error/exit logging.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Agents hit SDK papercuts constantly - a method returns success:true and
nothing happened, a dialog option isn't where the docs say it is - and
there was nowhere to put that. Filing one is now a single command:
bun sdk/bug-report.ts "walkTo returns success at closed gates"
The SDK's git commit is stamped on every report, so a report points at the
code that produced it.
Server side, POST /bug-report appends JSONL to the mounted volume
(BUG_REPORTS_FILE in fly.toml, so reports survive deploys). The endpoint is
deliberately write-only: nothing is served back over HTTP, and reports are
read off the machine with
fly ssh console -C "cat /opt/server/data/bug-reports.jsonl"
Reports always go to rs-sdk-demo.fly.dev, never to whatever SERVER a bot is
playing on - a bug found against a local server is still a bug in the SDK.
Telemetry is on by default; TELEMETRY=false in bot.env or the environment
turns it off.
Also renames claude.md -> CLAUDE.md and agents.md -> AGENTS.md. Those names
only resolved on macOS: the filesystem is case-insensitive here, so git had
recorded lowercase names that no tool would find on a case-sensitive
checkout. Verified on a case-sensitive volume - before the rename, lookups
for CLAUDE.md and AGENTS.md both missed; after, all three resolve to the one
real file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>