High-performance multiplayer on Spark
The recommended patterns for building smooth, low-latency realtime games and apps on spark.boqsc.eu. A complete reference implementation (NEON ARENA) lives at temp/spark_neon/ and is deployed at https://spark.boqsc.eu/~/sp_8a6384581f9168e4/. This page is the guidance distilled from it.
0. A downloaded single-file game → hosted multiplayer (the “double-click” flow)
You can produce one HTML file, the user downloads it and double-clicks it, and it becomes a live hosted multiplayer game. Do not try to keep it on file:// — file: URLs are unique security origins and history.pushState()/replaceState() will throw.
- Include the helper:
<script src="https://spark.boqsc.eu/spark-client.js"></script>. - Detect the environment with
Spark.environmentandSpark.project. - When running locally (
Spark.environment.localFile), callSpark.deploy(...)andlocation.hrefto the returned hosted URL. - When hosted (under
/~/sp_.../),Spark.project.idis already populated — connect to the socket directly.
<script src="https://spark.boqsc.eu/spark-client.js"></script>
<script>
if (Spark.environment.localFile) {
// User double-clicked the HTML. Create a hosted project in ONE request:
const app = await Spark.deploy({ index: document.documentElement.outerHTML });
location.href = app.scaffold.site_url; // no history.replaceState!
} else {
const project = Spark.project.id; // "sp_..." (auto-detected)
const ws = new WebSocket(`wss://spark.boqsc.eu/api/socket?project=${project}&room=arena`);
}
</script>
Spark.deploy(files, room) posts to POST /api/deploy, which creates the project and publishes the given HTML/files, then returns the hosted URL. The response never contains the secret spk_ key — only the public sp_... id and site_url. A runnable example is https://spark.boqsc.eu/blob-arena.html.
1. Use the binary protocol (it is the default)
The realtime socket speaks a compact binary protocol by default; JSON is legacy and requires an explicit ?fmt=j. Use the hosted helper https://spark.boqsc.eu/spark-client.js which provides SparkBinary (codec) and SparkInterp (interpolation + dead reckoning):
<script src="https://spark.boqsc.eu/spark-client.js"></script>
const ws = new WebSocket(`wss://spark.boqsc.eu/api/socket?project=ID&room=NAME`);
ws.binaryType = "arraybuffer";
ws.onmessage = (e) => { const m = e.data.byteLength !== undefined ? SparkBinary.decode(e.data) : JSON.parse(e.data); ... };
ws.send(SparkBinary.encode({ type: "event", event: "move", data: { x: 0.5, y: 0.5 } }));
Normalized {x, y} positions pack into 4 bytes; everything else keeps a compact JSON body so precision is preserved. Every broadcast carries a server clock ts and per-room seq for interpolation.
2. Event relay vs snapshot mode — the key tradeoff
Spark offers two ways to distribute positions:
- Event relay (default, no query param): every
moveis broadcast to the room immediately. Lowest latency, but downlink is O(peers × send-rate). Use this when latency matters most (small-to-medium rooms, fast games). - Snapshot mode (
?snap=<hz>): the server merges all players’ positions into ONE compact room frame per tick. Downlink is O(Hz) regardless of peer count — great for very large rooms — but positions are up to (tick interval + network) old, and the ticker can be delayed by server load. Use this when bandwidth/efficiency matters more than tick latency.
Combat, chat, and other action events are never coalesced — only move is. So a snapshot-mode room still relays shots/chat instantly.
3. Dead reckoning hides the remaining latency
Even in event relay, a remote player’s position is always ~1 network RTT old. SparkInterp smooths that with interpolation, and you can push it further with velocity extrapolation:
const interp = new SparkInterp({
delayMs: 30, // render slightly behind the newest sample (smoothing)
historyMs: 400, // keep this much history
extrapolateMs: 500 // dead reckoning: GLIDE through delivery gaps
});
extrapolateMs makes remote players keep moving at their last velocity during
delivery jitter instead of freezing-then-teleporting. Set it large enough to glide through the
gaps you actually see (this host: 200-800 ms occasionally). The render clock is derived from
the server ts directly, not a local clock-offset estimate, so the
observer’s own movement or load can never shift it and teleport the remote players.
4. Send rate and the room budget
Send positions at a steady rate that fits the per-peer budget: 480 messages / 10 s (e.g. 25 ms ≈ 40 msg/s → 400/10 s). Bursting over the budget causes the server to drop messages — visible as hitches. Keep non-position events (shots, pickups) sparse.
if (sendClock * 1000 > 25) { sendClock = 0; send({ type: "event", event: "move", data: state() }); }
5. Deploying the app (one request) and deploying updates
One-request create + publish: POST /api/deploy accepts a raw HTML body, a JSON {"files": {...}} mapping, or multipart/form-data. It mints a project, stores the files, and returns the hosted URL in a single call — no separate upload step needed:
curl -s -X POST https://spark.boqsc.eu/api/deploy \
-H "Content-Type: text/html" --data-binary @game.html
# → {"id":"sp_...","scaffold":{"files":["index.html"],
# "site_url":"https://spark.boqsc.eu/~/sp_.../","room":"main"}}
To update an existing project, upload over /api/files?path=... with the project key as Authorization: Bearer <key>. Files are served with Cache-Control: no-cache (revalidated with ETag), so a new upload is visible immediately. As a habit, still version script tags (<script src="./app.js?v=3">) so caching is never ambiguous.
6. Measure the right way
Avoid polling the browser page to measure realtime performance — the polling itself loads the main thread and induces the gaps you are trying to measure (this bit us: naive numbers showed 100-1000 ms gaps; in-game timestamps showed 30-50 ms). Instead, record arrival timestamps inside your message handler and read them once:
// in your onmessage handler:
dbg.arrivals.push(performance.now());
// after the test, diff consecutive entries and report median/p95/max.
Expected healthy numbers for event relay on this host: median ~30-35 ms, p95 < 60 ms. Occasional 200-800 ms gaps (<2% of messages) come from the host’s antivirus/GC pauses, not the game — dead reckoning bridges them.
Checklist for a smooth game
- Binary protocol (no
fmt=j),SparkBinary+SparkInterp. - Event relay for positions in small/medium rooms;
?snap=<hz>for very large rooms. SparkInterp({ delayMs: 30, historyMs: 400, extrapolateMs: 400 })for remote players.- Send positions every 25 ms (40 msg/s), stay under the 480/10 s budget, keep other events sparse.
- Version your
app.jsscript tags; files are no-cache now. - Measure in-game (arrival timestamps), not by polling the browser.
- Copy the reference (
temp/spark_neon/) and adapt the game logic inupdate()/draw().