Meme Wars · design notes

Worlds that stay.

Right now every room is a sandcastle — restart the server and it's gone. Persistent worlds let a room opt in to surviving: its terrain, the things built and broken in it, and (optionally) the stuff standing around, all reloaded exactly as they were, the way Minecraft or Terraria keep a save. The good news is you've already built most of the machinery. Here's what's there, what's left, and the calls worth making up front.

Exploratory · scoping for Set 13, not a spec · grounded in the current engine
Start here

You already have most of it

Persistence usually means inventing a serialization layer from scratch. Meme Wars doesn't need to — three existing seams do the heavy lifting:

The seams that make this cheap

  • Terrain is already a serialisable diff. Each room's world is a base generator + a typed overlay of edits (terrain.edits() / applyEdits()). Every player build and every blown-up tile lives in that overlay — it's tiny, and it already round-trips to clients over the wire.
  • Entities are plain data. The ECS is integer ids + components that are plain objects ({ pos:{x,y}, sprite:{…}, campfire:{} }). A world dumps to JSON by walking its component tables — no reflection, no special-casing.
  • Rooms already have a "never reaped" tier. Permanent rooms are exempt from the idle reaper today. Persistence is a second reason to keep a room alive — the same lever.

The one genuinely new thing is a place to put the bytes (a file per room) and a rule for when to write them.

The core decision

How much of a world survives?

This is the fork everything else hangs off. More persistence = more fidelity but more to serialise, version, and rebuild. The layers stack — each includes the ones before it.

Terrain

Your builds and the holes you blew. Already serialised — nearly free.

+ Placed things

Campfires, crops, dropped loot — the entities players deliberately made.

Everything

Enemies mid-charge, fish flopping, shots in flight. Rarely what you want back.

Layer 1

Terrain only

Persist the edit overlay. Builds and destruction survive; the world reloads as the shape you left it, empty of actors. This is what "my base is still here" means to most players.

  • Almost free — edits() already exists.
  • Tiny + trivially versionable (a list of tile ops).
  • Campfires / crops / dropped loot don't return.
Recommended first
Layer 2

+ Placed entities

Also persist a curated set of entities — campfires, ripening crops, dropped items — the things a player put there on purpose. Skip transient actors (enemies, projectiles).

  • The world feels genuinely lived-in.
  • Reuses the ECS dump + blueprints on load.
  • Needs a rule for WHICH entities persist (below).
Layer 3

Everything

A full world snapshot: every entity and component, enemies and in-flight state included. Complete, but mostly captures things you'd rather see reset on load.

  • Nothing to decide about scope.
  • Heavy; resurrects half-dead enemies + stale projectiles.
  • Physics bodies all rebuild at once on load.
What it looks like on disk

One file per room

A persistent room writes a single JSON file (data/rooms/<id>.json). It carries what a room needs to be re-created from cold: its identity + config, the terrain overlay, and — for Layer 2+ — the persisted entities. Everything in it already has a serialised form today.

// data/rooms/homestead.json — everything needed to rebuild the room from cold
{
  "id": "homestead", "name": "Homestead", "persistent": true,
  // how the base world is generated — identical seed => identical base terrain
  "seed": 88213, "gen": "one ground", "floor": "lava",
  "mode": "coop", "config": { "plugins": ["agriculture", "cooking"], "pluginConfig": { "hunger": { "rate": "slow" } } },

  // LAYER 1 — the edit overlay, exactly what terrain.edits() returns: [tcx, tri, packedTile]
  "terrain": [ [12,4,3], [12,5,3], [13,4,0] ],

  // LAYER 2 — persisted entities: component data only (physics bodies rebuild on load)
  "entities": [
    { "pos": {"x":640,"y":128}, "sprite": {"shape":"cooking.campfire"}, "campfire": {} },
    { "pos": {"x":712,"y":120}, "crop": {"stage":2}, "sprite": {"shape":"crop"} }
  ]
}

Note what's not here: no players (they reconnect), no enemies or projectiles (transient), no matter-js bodies (rebuilt from pos/body when the entity is restored). The base terrain isn't stored either — the seed regenerates it, and only the overlay of changes is kept.

When to write

Save cadence

Terrain edits and placed entities change slowly (a fight doesn't touch them), so this can be cheap. Three options, not exclusive:

Option A

On change, debounced

An edit / placement marks the room dirty; flush a few seconds later. Most durable — a crash loses seconds, not a session.

  • Barely loses anything.
  • Most write traffic.
Option B

Periodic autosave

Flush every dirty room every N minutes. Simple, bounded I/O, predictable. What most games do as their backbone.

  • Simple + bounded.
  • A crash loses up to N minutes.
Option C

On shutdown

Write on a clean stop (SIGINT/SIGTERM). Cheapest, but a hard crash or power loss saves nothing.

  • Zero steady-state cost.
  • Crash = total loss.

Recommendation: B + C — a periodic autosave of dirty rooms, plus a flush on clean shutdown. Add A's debounce later if a lost few minutes ever stings. Only dirty rooms write, so an idle persistent world costs nothing.

The room's life, extended

Lifecycle

Persistence is one flag plus two hooks on the lifecycle a room already has.

① Create

Wizard checkbox "persist this world" sets persistent:true — a plugin config field, same path as the rest.

② Live

Edits/placements mark it dirty. The autosave writes dirty persistent rooms to their file.

③ Empty

The idle reaper SKIPS persistent rooms (the existing never-reaped path). It stays.

④ Reboot

On startup the server scans data/rooms/ and re-creates each — seed + config, then applyEdits + restore entities.

Calls worth making up front

The decisions I'd want your steer on

None of these block Layer 1, but Layer 2 needs answers and they shape the format, so better to settle them before the entity work.

Which entities persist?
An explicit allowlist by component (campfire, crop, dropped pickup) or a persist tag a blueprint opts into. I lean a persist tag — a plugin marks its own entities persistable, which fits the "everything is a plugin" direction. Enemies, projectiles, players never carry it.
Players + inventory?
Not persisted for now. There are no accounts yet, so there's no identity to attach a saved inventory to on reconnect. When accounts land, per-player state is its own file. Until then a returning player starts fresh in a persistent world.
Physics on load
Matter-js bodies aren't serialised — they're rebuilt from pos/body when an entity is restored, exactly as spawning already does. Terrain colliders rebuild from the overlay via the existing rev counter. No new physics code, just ordering.
Format versioning
Stamp a "v" field in the file from day one. Terrain is a stable tile-op list; entity component shapes will drift, so a version lets a loader migrate or skip unknown components rather than crash.
Entity id stability
Saved entities get fresh ids on load (nothing off-world references them by id across a restart). Simplest and safe; only matters if cross-entity references by id ever need to survive.
If I had to pick a path

Ship it in layers

Each layer is useful and shippable on its own, and Layer 1 alone delivers the headline feature — "my world is still there."

Layer 1

Terrain persistence

A persistent room flag (wizard checkbox) → exempt from reaping, autosave edits() to a per-room file, reload with applyEdits on boot. Builds + destruction survive. Nearly all reused code.

Layer 2

Placed entities

A persist tag on blueprints; dump/restore tagged entities in the same file; rebuild physics on load. Campfires, crops, dropped loot come back.

Layer 3

Full snapshot (optional)

Only if a use-case wants enemies/state frozen and thawed. Same file, wider net. Probably never the default.

The short version

Do Layer 1 now: it's a room flag, a file, an autosave tick, and a startup scan — almost entirely seams that already exist — and it's the thing players actually mean by "persistent." Settle the five decisions above, then Layer 2 (placed entities) is the natural follow-up. Stamp a version in the file from the first write.