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.

  1. Include the helper: <script src="https://spark.boqsc.eu/spark-client.js"></script>.
  2. Detect the environment with Spark.environment and Spark.project.
  3. When running locally (Spark.environment.localFile), call Spark.deploy(...) and location.href to the returned hosted URL.
  4. When hosted (under /~/sp_.../), Spark.project.id is 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 move is 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.js script 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 in update()/draw().