sloptimize
v0.4.8
Published
The agent-native profiler for three.js games: an always-on flight recorder, per-entity cost attribution, and a deterministic bench — so a coding agent can measure, attribute, and verify instead of guessing.
Maintainers
Readme
sloptimize
sloptimize optimizes your game's rendering performance by finding the bottlenecks and reporting them to Claude Code to fix — all while you just play the game. No action is required on your end.
The agent-native profiler for browser games. Your coding agent cannot watch a game run — it will never feel a hitch, cannot screenshot 60 times a second, and cannot verify a "fix" it cannot measure. sloptimize gives the agent the three verbs it measurably lacks:
- MEASURE — an always-on flight recorder detects incidents (CPU spikes, fps drops, GPU stalls, and the player's unit or camera SNAPPING off its own trajectory) automatically, in the background, and writes them to disk before anyone asks. The human just plays.
- ATTRIBUTE — every incident arrives classified with evidence
(
shader-compile: programs +2,long-script, upload storms,snap 17.5m in one frame), stamped with a footprint — the identity of its cause and the game's situation (which machine, at the helm or on foot, in combat…), never its time — so one cause across builds, sessions and players is one issue with a count and a fix history, and — with the attach tier — named by function and file:line from a rolling sampling profiler. - VERIFY — exact counters (draw calls, triangles, pipelines — deterministic
on any renderer), perf budgets with exit codes, and honest labels: timing
numbers carry their regime (
hardware/software) and are never compared across them.
The division of labor is the design: the tool decides what is true; the agent decides what to try; the human plays.
Proven in production on a 149k-line WebGPU battle-royale: the pipeline caught a 205,000-calls/11s GPU upload storm from a player's real session, attributed it, and verified the fix at >60× reduction — with the player doing nothing but playing.
The pipeline
game/browser ──► incidents (auto-detected, classified, clustered)
│
▼
.sloptimize/ ◄── the agent's reading room
profile.json rolling summary (median/p95/counters/regime)
perf.jsonl incident records, append-only, each with its footprint
clusters.json one cause = one cluster
census.json per-entity cost census (tier 1+)
fixes.jsonl the fix ledger: issue → solution, commit, MEASURED before/after
budgets.json YOUR limits (the one human-authored file)
│
┌───────────┴───────────┐
▼ ▼
Claude Code (agent) in-game debugger (human, OPTIONAL)
woken on new incidents Session · Issues · Optimizations · Settings —
(fp=<id> ×N on each); this tab's incidents + a note box; every cause
reads, fixes, verifies, grouped by footprint with ×N, last seen and the
records each fix fixes applied; p95/calls/hitches over timeShowing the work is part of the loop: after a verified fix the agent runs
sloptimize fix --title … --issue … --solution … --commit <sha>, and the
record's before/after are two measured windows of the ledger (previous
build vs new build) — not numbers the agent typed. The debugger's Fixes tab
and sloptimize history read that ledger back.
Install
Published on npm as sloptimize.
Node 22+.
npm i -D sloptimize # in your game repo (recommended)
npx sloptimize --version # confirm: prints the installed versionNo install at all for a one-off: npx sloptimize attach --launch http://localhost:3000.
Working from a git checkout instead? node /path/to/sloptimize/bin/sloptimize.mjs …
runs on bare Node.
Zero dependencies, no postinstall, no supply chain — npm is delivery only.
Quickest start: zero integration (tier 0)
Requires only Node 22+ and a Chromium. No game changes, no build changes:
npx sloptimize attach --launch http://localhost:3000 --headless
# play / drive the game …then:
npx sloptimize reportAttach connects over the Chrome DevTools Protocol, injects a recorder before any page script (rAF timing, draw/triangle counts via graphics-API wraps, pipeline creations WITH call stacks, upload bytes, GPU queue latency), and runs a rolling sampling profiler so a freeze is attributed like:
INCIDENT long-script|[email protected]:512 — 900msLimits, stated: Chromium-only; minified bundles attribute to minified names unless you serve sourcemaps; entity-level attribution needs tier 1+.
Higher fidelity: the in-page feed (tier 1)
One call per frame from wherever your loop already reads renderer.info:
import { createRecorder } from 'sloptimize';
const rec = createRecorder({ budgetFrameMs: 16.7 });
// per frame:
rec.frame({ frameMs, insideRenderMs, calls, triangles, programs,
geometries, textures, spawned, paused });
// optional human channel (bind to a chord, e.g. Ctrl+F11):
rec.usermark({ windowMs: 5000, note, inputsHeld, world });Ship the records to .sloptimize/ however your stack likes — a vite host
gets a plugin (planned); any other host adds one dev-gated POST endpoint
(~100 lines; see docs/INTEGRATION.md for the reference implementation,
including the four traps that cost the first deployment real time:
don't gate activation on hostname (probe your dev endpoint instead),
give the recorder its own rAF clock (a game-loop-fed clock is blind to
boot/launch — exactly the windows you care about), frameMs must bound
insideRenderMs, and never let the feed die silently (retry the probe
and the posts on a backoff, buffer while dark, and SHOW the state — the
first deployment lost an hour of real freezes to a server restart that
dropped the ingest with no indication anywhere).
The wire contract the reference runtime keeps, so the files are useful on their own:
- every record is self-sufficient —
build(which bundle the tab runs) andphase(menu/boot/launch/match…) ride each ledger line; hitches are stamped at mint time, not post time; - a heartbeat record lands once a minute while armed, so a quiet
perf.jsonlmeans "no session, or the feed is dark" — never just "idle" (sloptimize hook-statuswarns when the ledger goes stale); - gpu-settle records report how long a boot/reveal gate actually waited
on
onSubmittedWorkDone— the on-hardware verification channel for compile-stall fixes; - a hitch that overlapped pipeline/shader creates carries
createStacks— the top 3 dedupedError().stacktails from the create wrappers (~2KB cap), so aprograms +Nhitch from a machine you cannot profile names its own call sites. The positions are minified (bundle.js:L:C); keep an unreferenced sourcemap at build time and decode locally (the game repo'stools/decode-perf-stack.mjsis a dependency-free reference decoder).
Coordinate jitter and the issue catalogue (tier 1, ~200 lines in the
game): feed the unit's and the camera's positions once per rendered frame
and a snap or oscillation lands as a classified jitter record; declare a
few facets of the player's situation and every incident of every kind is
footprinted, counted and linked to its fixes — the debugger's Issues
tab, sloptimize issues, and fp=<id> ×N on every wake line. The whole
recipe, with the three traps that make a naive position detector lie
(rotation, transient shakes, the sim's dt clamp), is
docs/JITTER-AND-FOOTPRINTS.md.
import { createMotionMonitor, canonicalContext, footprintOf } from 'sloptimize';
const motion = createMotionMonitor({ unit: 'm', longFrameMs: 50,
tracks: { unit: { floor: 0.1 }, camera: { floor: 0.1, reach: 'boom', follows: 'unit' } } });
// per rendered frame, after the render:
motion.sample('unit', pivot.x, pivot.y, pivot.z, now, { held: paused, phase, ctx });
motion.sample('camera', cam.x, cam.y, cam.z, now, { held: paused || lookInput, reach, phase, ctx });
// once a second: ctx = canonicalContext({ stance: 'helm', hull: 'elong-x', squad: 'duo', combat: 'no' });
// at post: for (const r of records) { r.ctx ??= ctx; const fp = footprintOf(r); if (fp) r.footprint = fp; }Tier 2 (scene census, per-entity attribution, measured bisection) layers on
top where the engine grants scene access — see docs/SPEC.md §4.
Claude Code integration — the whole point
The npm package IS a Claude Code plugin — skill, prompt hook, and MCP server
ship inside it. After npm i -D sloptimize, point Claude at it:
claude --plugin-dir node_modules/sloptimizeAlternatives: claude --plugin-dir /path/to/sloptimize from a git checkout,
or via the marketplace:
/plugin marketplace add m0dE/sloptimize then /plugin install sloptimize.
Then let the agent wire your game: /sloptimize:install walks it through
the tier-1 integration (runtime, sink, budgets, hooks) and refuses to call
itself done until the feed is proven live end-to-end.
That carries three surfaces into every session:
- Skill — the doctrine: read → classify → census → ONE change → verify with counters; never claim a perf fix without a measured before/after; never quote timing from a software regime.
- Prompt hook — silent by default; when a NEW keyframe or budget breach exists, up to five lines land in the agent's context on your next prompt.
- MCP server —
get_report,check_budgets,get_history,get_issues(the catalogue by footprint),record_fix(with the footprints it addresses), andattach_start/attach_stopfor the live tier.
For instant wakeups (the agent starts fixing ~20s after the stutter, no
prompt needed), arm sloptimize watch as a session Monitor — one line, in
docs/INTEGRATION.md §5. Wire it into a SessionStart hook and every
session arms it by itself.
Cloud — sloptimizejs.com
Everything above is local: one machine's .sloptimize/ directory, read by
that machine's shell and that machine's Claude Code session. sloptimize
cloud widens the same catalogue to every player, every build — not just
the one in front of you: an issues catalogue across 24h/7d/30d or any range,
a timeline of p95 frame time, draw calls and incidents across all tabs with
build boundaries and fix markers, one page per player session, and every fix
measured against what players saw. Client incidents and server incidents
(sloptimize/node) fold into the same footprint identity; uncaught errors
(createErrorMonitor) are their own incident kind. The local product stays
the default story — nothing below changes what a project with no cloud key
does.
Getting on it takes three steps:
- Sign in at sloptimizejs.com with GitHub. The free plan (5,000 incidents a month, 7 days of raw records, one project) needs no card; Pro and Team lift the limits.
- Create a project and open its settings page: it holds the two keys and the same three snippets below, filled in.
- Paste the snippets. The endpoint is
https://sloptimizejs.com/v1/ingestfor both sinks; the CLI and MCP take the base,SLOPTIMIZE_ENDPOINT=https://sloptimizejs.com.
// browser: the cloud sink is a TEE beside your existing drain, never instead
// of it — errors ride the same recorder as hitches, so one drain feeds both
import { createRecorder, createErrorMonitor, createCloudSink } from 'sloptimize';
const rec = createRecorder({ budgetFrameMs: 16.7 });
createErrorMonitor(rec);
const cloud = createCloudSink({ key: 'pk_live_…', endpoint: 'https://sloptimizejs.com/v1/ingest', build });
// in the ~2s drain you already have (docs/INTEGRATION.md §1):
const batch = rec.drainRecords();
post('records', batch); // unchanged: .sloptimize/perf.jsonl, still the source of truth
cloud.enqueue(batch); // the same records, teed to the cloud sink's own queue(The sources: [rec] option exists only for a host with no file sink at all:
the sink drains those sources itself, so anything it takes never reaches your
own drainRecords().)
// game server (Node): ticks, event-loop stalls, and uncaught errors
import { createServerRuntime } from 'sloptimize/node';
const server = createServerRuntime({ key: process.env.SLOPTIMIZE_KEY, endpoint: 'https://sloptimizejs.com/v1/ingest', build, tickBudgetMs: 16 });
server.tick(() => stepWorld()); // a tick over budget is a server-hitch, attributed by the V8 samplerThe server runtime registers uncaughtExceptionMonitor only, so it observes
a crash without ever becoming part of the crash path. One consequence worth
knowing: under --unhandled-rejections=warn or none, unhandled rejections
are not captured — that event sees them only in Node's default throw
mode, and listening to unhandledRejection instead would suppress the throw
your process relies on.
# CLI: read the cloud catalogue instead of this machine's ledger
export SLOPTIMIZE_KEY=sk_live_… SLOPTIMIZE_ENDPOINT=https://sloptimizejs.com
npx sloptimize issues --cloud --preset 7d
npx sloptimize fix --title "…" --push # records locally, then pushesHonesty is the whole pitch: a dropped-locally count rides every batch the sink sends, so the dashboard's numbers say what they could not see rather than pretending nothing was lost.
Two kinds of key, and the difference matters. The publishable key is
public and write-only (it can post incidents, never read anyone else's), so
shipping it in a client bundle is the intended use, not a leak — that is the
key in the browser snippet above. The secret key is the one the server
runtime, the CLI (SLOPTIMIZE_KEY) and the MCP server use: it reads your
whole catalogue (/v1/issues) and writes fixes (/v1/fixes). A secret key
never goes in a client bundle.
Budgets: "fast enough" as an exit code
.sloptimize/budgets.json (the one file a human reviews):
{ "perf.budget.draw_calls": 400, "perf.budget.frame_ms_p95": 16.7 }npx sloptimize check # exit 0 inside · 1 breached · 4 unmeasuredThat exit code is what lets an agent self-iterate in a loop that terminates.
CLI
sloptimize report current profile + incidents + census hints
sloptimize check budgets → exit code (--counters-only for CI)
sloptimize census per-entity costs + closed-vocabulary hints
sloptimize history the timeline: p95 / draw calls / hitches per time
bucket and per build, plus the fix ledger
sloptimize fix record a verified fix (title, issue, solution,
commit) with MEASURED before/after windows
sloptimize attach tier-0: --launch <url> [--headless] [--port N]
sloptimize hook-status the prompt hook's ≤5-line ambient surface
sloptimize issues the catalogue: every incident grouped by FOOTPRINT
(cause + situation, never time) — how often, how
recently, which fixes were applied; --fp <id> for one
sloptimize watch the push channel: one stdout line per usermark /
≥100ms hitch / gpu cap-hit / coordinate jitter /
feed dark, each with fp=<id> ×N; never exits
sloptimize doctor what is wired, what is degraded, stated limitsWhat it will tell you it cannot do
Printed by doctor, kept in the spec, never silently degraded: no per-draw
GPU timing; bisection ranks rather than sums; workload repro, not trajectory
repro; timing from software renderers flagged and never compared; V8
inlining can split an incident cluster across an optimization boundary;
correctness bugs are out of scope — a profiler cannot find a logic bug,
and the doctrine routes "it looks/behaves wrong" reports away before anyone
burns a loop on them.
Docs
docs/USAGE.md— day-to-day use once wired: the operator's verbs, new-session pickup, multi-session semantics, monitoring optionsdocs/SPEC.md— the founding specification (recorder, census, bench, anti-gaming posture)docs/SPEC-attach.md— v2: the incident pipeline, tier-0 attach, measured exit criteriadocs/INTEGRATION.md— wiring a real game + Claude Code session, with the reference deployment's trapsdocs/DESIGN-mecharoyale-v0.md— the first field deployment's decision record
Status
M0–M2 (recorder, census, budgets/CLI) and M-A0–A2 (attach, incident identity, plugin packaging) shipped with measured exit criteria. Bench + correctness gate (SPEC §6, M3) and paused-world bisection (M4) are next.
Relationship to slopjs
A sibling on the same platform: slopjs is a pointing device for a
human-in-the-loop authoring session; sloptimize is a measurement loop that
works with nobody watching. Tier 2 consumes @slopjs/inspector primitives
(stable IDs, the coherent pause, snapshots) where present.
License
MIT
