Making Every Operation in a Web Single-Player Game Truly Atomic
On this page
"Saving the game after a command executes successfully" is not an atomic operation. As long as state, random numbers, saves, and the interface can succeed independently, they can also fail independently.
Web single-player games often have a seemingly safe structure: gameplay logic executes in memory, writes JSON to IndexedDB upon success, and finally refreshes the interface. The happy path is short and easy to test; the real trouble comes from disk write failures, multi-tab race conditions, page crashes mid-operation, and players retrying the same operation.
Consider an operation to "spend resources and generate an item":
- Resources have already been deducted in memory;
- Random numbers have already been drawn to determine item attributes;
- IndexedDB write fails;
- The interface still displays the acquisition result, or memory continues to accept the next command.
At this point, whether you choose to keep the memory state or revert to the old save, at least part of the facts are inconsistent. More dangerously, such errors usually don't immediately corrupt the save; instead, they manifest as duplicate charges, duplicate drops, shifted random sequences, or missing result pop-ups on the next refresh, retry, or settlement.
This article documents a commit protocol suitable for scenarios where "the authoritative gameplay kernel runs in the browser process, and IndexedDB is the only persistent medium." It borrows ideas from database transactions but is not a distributed two-phase commit: the system has only one persistent participant, aiming to ensure that memory, persistence, and visible results acknowledge only one complete payload.
First, Define What Exactly a Command Needs to Protect Atomically
The atomic unit cannot simply be written as "player state." A semantic command must at least jointly own:
- Domain state, such as resources, characters, inventory, and current activities;
- The random ledger and all its cursors;
- Monotonic revision;
- Causal results and receipts of the command;
- Request deduplication records;
- Pending results awaiting player confirmation;
- The observation time used for this save.
They must be committed together or abandoned together. Especially random numbers: if a failed command consumes the random stream, the player sees "state unchanged," but subsequent drops are already different. This is still an observable partial commit.
The save structure should also strive for a single authoritative root:
Profile {
revision,
random_ledger,
permanent_state,
active_run?,
background_jobs,
active_battle?,
pending_receipt?,
processed_requests
}
Interface routing, current tab, reconstructible previews, and animation progress do not need to enter this root. Conversely, if losing them after a refresh would change gameplay facts or cause duplicate execution, they must be saved together with the root payload.
A single root is not to make the JSON larger, but to narrow the scope of correctness issues: IndexedDB only needs to atomically replace one record, without needing to prove that multiple object stores, logs, commit markers, and recovery branches are always consistent.
Command Envelopes Need Four Types of Identity
A complete command envelope can be abstracted as:
CommandEnvelope {
request_id,
expected_revision,
command,
preview_fingerprint?
}
The four parts solve four different problems:
| Field | Question Answered |
|---|---|
request_id | Is this a retry of the same user intent? |
expected_revision | Is the user operating on the current state? |
command | What exactly does the player request to execute? |
preview_fingerprint | Is the submission still the exact set of conditions and consequences previewed earlier? |
The kernel should also calculate a command_digest for the normalized command. Thus, request processing rules can be precisely defined as:
- Same
request_id, same digest: Return the original result from the first execution, marked as replay; - Same
request_id, different digest: Reject, indicating the client erroneously reused the request identity; - New request but
expected_revisionis stale: Reject and require refresh; - Same revision but preview fingerprint no longer matches: Reject and re-project the preview.
The order of checks is crucial. Request replay must occur before revision validation. When retrying a successfully submitted request, the client usually still carries the old revision; if revision is checked first, it would be falsely reported as stale instead of returning the replayed original result.
First Layer of Atomicity: All Logic Executes on a Private Draft First
The kernel should not directly modify the current game. The simplest and most reliable method is to clone the full authoritative root and execute on a private draft:
execute(current, envelope):
digest = digest_of(envelope.command)
if processed_requests contains envelope.request_id:
if saved_digest == digest:
return saved_result with replayed = true
reject REQUEST_ID_CONFLICT
if an unacknowledged receipt blocks this command:
reject RECEIPT_PENDING
if envelope.expected_revision != current.revision:
reject STALE_REVISION
draft = clone(current)
receipts = draft.apply(envelope.command) // Random numbers only modify the draft
draft.revision += 1
feedback = diff_causally(current, draft)
result = make_result(receipts, feedback, draft.revision)
draft.processed_requests[request_id] = { digest, result }
draft.pending_receipt = maybe_make_pending(result)
return PreparedCandidate { draft, result }
If any validation or domain operation fails, the draft is simply destroyed. The current state, revision, random cursors, request table, and receipts remain completely untouched.
Here, you should not just clone a sub-module deemed "likely to change." A single command often spans multiple domains: deducting resources, creating instances, changing ownership, updating unlocks, advancing quests, and generating feedback. Manually maintaining compensation operations or field whitelists easily leads to omissions in the random ledger, ID allocators, or new states as features grow. For small-to-medium single-player profiles, a full draft is usually cheaper and easier to prove than complex rollback logs.
Second Layer of Atomicity: Prepare, Persist, Publish
Even if draft execution succeeds, it cannot immediately replace the formal game in memory, because IndexedDB might still fail. The host boundary should split the latter half into three distinct steps:
Prepare
The adapter layer clones the current game, executes the command on the candidate, exports the complete candidate save, and calculates:
before_digest = digest of the currently persisted payload
after_digest = digest of the candidate's complete payload
The adapter layer allows only one prepared candidate to be retained at a time. Semantic commands within the same browser page are serialized via a Promise queue to prevent two asynchronous writes from holding the same base simultaneously.
Persist
The host reads the current record, compares the digest, and replaces it within the same IndexedDB read-write transaction:
replace_profile(expected_digest, next):
begin readwrite transaction
current = get("profile")
current_digest = current == null ? null : current.digest
if current_digest != expected_digest:
abort CONFLICT
put("profile", next)
commit
This comparison cannot be done as a read-then-write outside the transaction. Otherwise, two tabs might read the old digest simultaneously and then overwrite each other sequentially. Placing the read, comparison, and put into the same read-write transaction allows IndexedDB's transaction serializability to protect the CAS.
The command queue on the same page solves local concurrency; CAS solves races caused by other tabs, old pages, or external imports; the two cannot replace each other.
Publish
After the database commit, the host re-reads the record and then lets the kernel publish:
- The request ID must match the prepared candidate;
- The current memory's base digest must still equal
before_digest; - The re-read JSON must match the candidate save content;
- The JSON must pass validation against the current save structure and domain invariants;
- The re-calculated digest must equal
after_digest.
Upon passing, the kernel should reconstruct the formal game from the actually re-read payload, rather than directly adopting the previous memory draft. This way, memory acknowledges the state expressed by the exact bytes in the persistent medium, preventing serialization omissions, host mis-transmissions, and digest mismatches from quietly crossing the boundary.
Only after publish succeeds does the interface refresh the authoritative snapshot and display the success receipt.
When Failing, Only Recognize the Winner in the Persistent Medium
The most valuable aspect of this protocol is not a prettier happy path, but that every failure window has a unique recovery rule:
| Failure Location | Persistent State | Recovery Action | Player Visible Result |
|---|---|---|---|
| Command validation fails | Old payload | Discard draft | State unchanged |
| Draft execution fails | Old payload | Discard draft and its random draws | State unchanged |
| CAS detects base conflict | Other written payload | Discard candidate, reload save winner | Display state refreshed |
| IndexedDB write fails | Old payload | Abort prepared, reload old save | State unchanged |
| Write success, exception before publish | New payload | Reload new save | Committed, do not rollback to old state |
| Publish validation fails | Based on database | Discard candidate memory, re-import database record | Do not continue using suspicious memory |
| Refresh before successful result display | New payload | Restore result from saved receipt | Do not re-execute command |
Note the fifth row: once the database has successfully replaced, the operation cannot be described as "did not happen" just because JavaScript throws an error later. The correct action is to read the persistent record and adopt it. The commit point is when the IndexedDB transaction completes; publish merely allows memory to acknowledge this fact.
Request Replay Records Must Be Archived with Command Results
Saving only the most recent request IDs in memory is insufficient to handle refreshes and crashes. The complete profile should save:
processed_requests[request_id] = {
command_digest,
original_result
}
The original result includes not just "success," but also revision, domain receipts, numerical deltas, and causal feedback. When the same request is replayed, this result is returned without re-deducting charges, re-minting, or re-consuming random numbers.
This requires the client to bind the request ID to a user intent, not to a function call. Reuse the original ID on transport timeouts or host retries; only generate a new ID when the player initiates a new semantic operation.
Deduplication records cannot grow indefinitely. Use a bounded window, clean based on confirmation watermarks, or compress with save generations, but cleanup rules must satisfy: requests that might still be retried and pending receipts must never be deleted first. Capacity strategy is part of the protocol and should not be handled ad-hoc after save bloat occurs.
Receipts Are Also State, Not One-Time Popup Parameters
Some commands produce major results the player must see. If a command is already persisted, but the page refreshes before the popup appears, relying solely on component-local state will permanently lose the feedback.
You can save a pending_receipt in the profile, referencing the request ID, and copying the causal results to be displayed. Save validation should also prove:
- The pending receipt corresponds to a processed request;
- Their request IDs, receipt IDs, and result contents match;
- Other semantic commands that might overwrite context are blocked until the player explicitly confirms;
- "Confirm receipt" itself is a typed, persistable idempotent command.
This is not to stuff all UI state into the save, but to protect causal information that has been submitted but not yet consumed by the player. Ordinary toasts, tabs, and expanded states can still be transient interface states.
Why Digests Cannot Directly Hash JSON Strings
The same logical object can produce different JSON due to field order, whitespace, escaping, or serializer differences; different numerical semantics might also be compressed into the same floating-point representation by some runtimes. Directly hashing the raw JSON string for CAS identity mistakes representation details for state identity.
A more robust protocol is:
- Restrict values to explicit data models;
- Use deterministic encoding, such as canonical CBOR;
- Use shortest representation for integers and lengths;
- Sort map keys stably by encoded length and bytes;
- Reject floats for authoritative values, using integers or fixed-point instead;
- Wrap the digest input with a schema, version, and domain tag;
- Finally, use a stable digest algorithm like SHA-256.
digest = SHA256(canonical_encode([
"protocol-digest",
schema_version,
domain, // command / save / preview / content
value
]))
Domain separation prevents the same bytes from being mistaken for the same proof in different contexts. For example, a command digest cannot be used to impersonate a save digest. Canonical encoding also requires golden tests to ensure different container insertion orders yield the same result, different domains yield different results, and float authority is explicitly rejected.
Revision Cannot Replace Preview Fingerprint
Revision only proves "whether the entire profile has been advanced by another command." It cannot fully express the preview the player just saw.
For example, an operation page displays exact costs, source objects, targets, and consequences. Upon submission, calculate the preview_fingerprint by combining these decision-influencing inputs along with revision, current active state, and content definitions. The kernel re-calculates and compares before formal execution:
fingerprint = digest(
domain = "resolution-plan",
value = {
revision,
active_state,
relevant_profile_state,
content,
submitted_intent
}
)
If content hot-updates, target objects change, cost sources change, or the client's submitted intent does not match the preview, a stale preview will be detected even if the surface revision shows no difference. The recovery action is not to guess the player still agrees, but to re-project the page for player re-confirmation.
Not every button needs a preview fingerprint. It suits commands where "the player makes a confirmation based on a set of visible consequences"; simple tab switching and pure navigation do not belong to semantic commits.
The Interface Can Provide Instant Feedback, But Cannot Publish Authoritative Results Early
For feel, buttons can immediately enter loading, play press effects, or even display reversible intent animations after being pressed; but new balances, items, quest results, and success receipts can only come from the authoritative snapshot after publish.
Especially avoid the following sequence:
setState(candidate)
showSuccess(candidate.result)
await save(candidate)
If saving fails, the component tree, routing, animations, and prompts all need manual reversal; any omission will form ghost states. A simpler sequence is: lock only the current operation during preparation; refresh the complete projection once after persistence succeeds; reload the persistent payload on failure, and generate the interface from it.
How to Test
Atomic protocols cannot be accepted just by verifying "save content is correct after execution." At least the following invariant tests are needed:
- Complete save JSON is identical before and after invalid commands;
- Failed commands do not advance revision, instance IDs, or any random cursors;
- Same request ID, same command returns original result only, no repeated side effects;
- Same request ID, different command is rejected;
- Request replay hits replay first even if carrying old revision;
- Pending receipt blocks other semantic commands, releasing only after confirmation;
- Pending receipt and processed request must be consistent in save validation;
- After IndexedDB write exception, memory reloads old payload;
- After CAS conflict, adopt persistent result from another tab, do not overwrite it;
- Inject exception after database commit but before publish; refresh still restores new state;
- Publish rejects if receiving different bytes, wrong digest, or illegal save;
- If any input relied upon by preview changes, old fingerprint becomes invalid;
- Canonical digest has cross-platform golden tests and rejects float authority;
- Continuous rapid clicks are strictly serialized within a single page.
The most effective is fault injection: throw errors after prepare, before CAS, after transaction completion, and before publish, then check persistent payload, memory snapshot, random ledger, and visible receipts. Testing only domain functions cannot cover the truly dangerous host boundaries.
What This Solution Does Not Solve
- It is not an anti-cheat system. If the local player fully controls the browser and files, they can still tamper with data; digests are mainly for consistency and conflict detection.
- It does not replace save versions and domain validation. A payload with a correct hash may still not comply with current rules.
- It is not a distributed transaction across servers. If cloud saves or payment services are added, new synchronization and conflict protocols are needed.
- It does not require all interface state to be archived, only protecting state that changes gameplay facts or leads to result loss.
- It does not automatically decide request record retention periods, cross-device merging, or clock trustworthiness; these still need separate design.
Minimum Implementation Checklist
- Only one complete, verifiable authoritative profile root;
- State, random ledger, revision, receipts, and request records committed together;
- New commands check request replay first, then pending receipts and revision;
- Same request ID, same digest replays; different digest rejected;
- All domain modifications first occur on a complete private draft;
- Adapter layer retains only one prepared candidate;
- Commands on the same page are serialized; multi-page writes use transaction-internal CAS;
- Do not publish new memory or new interface before IndexedDB success;
- Publish reconstructs authoritative state from the actually re-read payload;
- Any failure discards the candidate and reloads the winner from the persistent medium;
- Important receipts and deduplication records enter the same save root;
- Commands, saves, and previews use domain-aware canonical digests;
- Perform fault injection testing on every commit window.
Conclusion: There Can Be Only One Commit Point
Web single-player games, while lacking remote servers, still possess multiple state carriers: memory, WebAssembly, IndexedDB, and React. As long as they are all treated as "approximately authoritative," crash windows will become gameplay bugs.
This protocol ultimately does only one thing: fix the commit point to a successful persistent atomic replacement. The kernel draft ensures domain state and random numbers succeed or fail together; CAS selects the concurrent winner; publish ensures memory acknowledges the fact that has been written to disk; request records and persistent receipts ensure refreshes and retries do not create second results. Once boundaries are clear, error handling becomes simpler than "saving after execution."
Keywords: Game Development, WebAssembly, IndexedDB, Atomic Commands, Compare-and-Swap, CAS, Idempotency, Request ID, Revision, Canonical Digest, Save Transactions