DEV Community

Reallexi LLC
Reallexi LLC

Posted on

Building BlockMyBlocks: A balance game for all ages

BlockMyBlocks is out now on Web, iOS, Android. This is how it got made: the decisions, the pipeline, and what I would do differently.

What it is

A balance game for all ages. Stack the pieces on a deck that tips, hold it steady, and keep going — the levels never run out.

Stack the pieces on a deck that tips, hold it steady, and keep going — the levels never run out.

Determinism and golden tests

The sim is pure and deterministic — time and seed are parameters. That buys the strongest cheap test there is:

  • The catch-up law: simulate(state, a+b) === simulate(simulate(state, a), b) for arbitrary splits. Offline catch-up, tab-restore and server ticks all depend on it. Test it directly with quantised splits (e.g. 120 = 60+60), plus: rewards are integers, no NaN anywhere after a very long run.
  • Golden files: shared/test/golden/.json holds {seed, state0, actions[], expected: {hash per interval, final snapshot}}. The test replays the actions and compares a stable hash of the state at every interval. Any sim change fails golden; --update regenerates only after a human confirms the change is intended and the PR says why. Keep scenarios small but adversarial (empty world, dense world, each subsystem stressed, one long-horizon run).
  • Cross-runtime determinism: the browser smoke computes the same hashes with the same shared sim and must match the server — catches engine/float differences before they become a

Scene architecture

Rules that keep the frame loop cheap:

  • State reads in useFrame go through store.getState(), never per-instance subscriptions. Selectors are narrow (useWorld(s => s.entities)).
  • HUD is DOM, not canvas. Everything tappable is React DOM for accessibility and crisp text; in-world labels are drei with a hard cap (~40 visible, nearest first).
  • Zero per-frame allocation in steady state: pre-parsed THREE.Color constants (col.copy(c), never col.set('#hex')), module-level scratch vectors, geometry rebuilds gated on a content signature, not on "something changed".
  • Debug handles live on window.game: stats() (draw calls, tris, dpr, tier, fps), mem() (GPU bytes by owner), setQuality, setDpr, setTimeOfDay, governor flag. Test scripts read these; nothing in gameplay

Recommended platform collections

The generic meta-game set. Add game collections beside them; keep these shapes so payments, social and live-ops code stays portable between games. All timestamps epoch-ms.

  • Collection: Id Fields
  • accounts: account id (nanoid) displayName, isGuest, providers: [{provider: 'guest'\ 'google'\ 'apple'\ 'email', subject}], email?, passwordHash?, locale, country?, createdAt, lastSeenAt, bannedAt?, bannedReason?
  • sessions: opaque 32-byte token (never logged) accountId, deviceId?, createdAt, expiresAt, lastUsedAt
  • wallets: : balance (integer ≥ 0, a cache of the ledger sum), updatedAt
  • wallet_ledger: : accountId, currency, delta, balanceAfter, reason ('collect'\ 'purchase'\ 'gift'\ 'event'\ 'admin'\ 'refund'…), refType?, refId?, at — append-only; the ledger is truth
  • purchases: : accountId, productId, provider ('stripe'\ 'play'\ 'apple'), state ('pending'\ 'granted'\ 'refunded'\ 'failed'), amountCents?, currency?, createdAt, grantedAt?, refundedAt?, rawReceiptRef?
  • entitlements: : key ('season_pass:', 'no_ads', 'pack:'), sourcePurchaseId?, grantedAt, expiresAt? (null = permanent), revokedAt?
  • leaderboard_entries: :: score, meta? (display extras, id-only), updatedAt; periodKey = 'all', ISO week '2026-W35', or an event id
  • flags: flag key value, segments? ({country?, minLevel?, pct?}), version, updatedAt — live-ops overrides on top of content
  • events: event id runtime…

Validator-per-message

Every message type — WS and REST body alike — has exactly one validator function in shared/protocol.mjs, used by the client before sending and by the server before touching state. Validators are whitelists: the only fields that exist afterwards are the ones they return.

Unknown t values and oversized frames (> 16 KB) close the socket; unknown fields are dropped; null from a validator is BAD_PAYLOAD, before any service code runs. There is never a second, looser parser

What is next

It is live at https://ddkits.github.io/block-my-blocks. If you have shipped something similar, I would like to hear what you would have cut.

Top comments (0)