The Ops Community ⚙️

spryo xu
spryo xu

Posted on

Separating Gameplay from Leaderboard Infrastructure in a Browser Game

A browser game can run locally while still needing a backend for shared records. The interesting engineering question is deciding where those two parts should meet.

I’m building MojiBlade, a browser game where players choose a character and a typeface, then launch that character into battles. The shape of the character is part of the game, which makes trying different combinations central to the experience.
MojiBlade gameplay: characters battling in the arena

The gameplay runs in the browser. Shared leaderboards introduce a separate set of concerns: player identity, score submissions, database operations, publication schedules, and failure handling.

Here is how I have separated those responsibilities using Cloudflare Workers, D1, KV, and Durable Objects.

Keep the game loop in the browser

Rendering, input handling, and combat do not need a server round trip for every action.

The browser handles those interactions, while the backend handles shared records. Local storage also preserves browser-side state between visits.

This creates an important distinction: completing a battle and synchronizing its record are separate events.

A player can finish a local game even when a score upload needs to be retried. However, a locally saved record should not be presented as proof that the shared leaderboard has accepted it.

For an interface, that means distinguishing local progress from synchronization status.

Give public rankings a separate read path

For the lifetime leaderboard, public requests read a published snapshot through a cache and KV path.

The authoritative records live in D1. A publication process turns those records into a public top-100 snapshot.

The flow is:

  • The browser submits a record to the Worker API.
  • The API checks player ownership and processes the submission.
  • A scheduled publisher checks for leaderboard changes.
  • The publisher writes an updated public snapshot to KV when needed.
  • Public leaderboard requests read the snapshot.

The deployment configuration schedules publication checks every ten minutes.

That is a deliberate freshness tradeoff. A public leaderboard can lag behind an accepted submission, so it should not imply that every successful upload immediately changes the displayed ranking.

This arrangement also makes public reads easier to reason about: opening a leaderboard does not need to trigger a new ranking query against D1.

Use revisions to avoid unnecessary publication

A scheduled task does not necessarily need to rebuild its output on every run.

The lifetime publisher checks a revision value before rebuilding the snapshot. If the revision has not changed and there is no pending publication, it can return without publishing again.

When a rebuild is necessary, the implementation reads the revision and ranking entries in the same D1 batch transaction. This keeps the snapshot’s version tied to the records used to construct it.

There is another useful check after that: a database revision can change without changing the public top 100.

If the resulting entries match the previous snapshot, the publisher can update its internal revision without rewriting the same public content to KV.

These checks address different questions:

  • Has the underlying ranking state changed?
  • Has the public output actually changed?

Separating those questions avoids treating every internal update as a reason to republish.

Make publication recoverable

Publication crosses storage boundaries. Reading D1 successfully does not guarantee that the following KV write will succeed.

The Durable Object publisher stores a pending candidate before attempting the KV write. If publication fails, that candidate remains available for a later attempt.

After a successful write, the publisher records the published snapshot and clears the pending state.

The publisher also coalesces overlapping refresh calls into one in-flight job. This matters because asynchronous storage operations can overlap: without coordination, an older job could finish after a newer one.

The aim is a publication process that can recover from partial completion without losing track of what it was trying to publish.

Define the public data explicitly

A leaderboard needs player-facing fields such as a display name, character, typeface, and score. It does not need authentication material.

The snapshot validator checks the expected structure, limits the number of entries, rejects duplicate player IDs, and rejects entries containing token fields.

That is separate from checking ownership on a score submission.

It is also worth being precise about the security boundary: verifying that a submission belongs to a player identity does not independently verify that a browser-computed score was legitimately earned.

Those are different problems and need different controls.

Test the boundaries between components

For this architecture, useful acceptance checks include:

  • Can a player finish a battle when synchronization is unavailable?
  • Is an accepted submission actually stored?
  • Can a logged-out visitor load the public ranking?
  • Does failed publication leave a recoverable candidate?
  • Does the public snapshot exclude authentication data?
  • Does the interface avoid presenting an older snapshot as an immediate update?

A successful API response, a saved database record, and a visible public ranking are three separate pieces of evidence.

For this project, separating them has been a useful way to reason about the backend. Gameplay, record submission, and public publication each have their own timing and failure conditions. Giving each a clear responsibility makes the whole system easier to inspect.

Top comments (0)