Files
Emberwild/docs/WORLDLETS_DESIGN.md

8.3 KiB

Worldlets design

Status: proposal. Nothing here is built yet, apart from the first piece of groundwork (world.water_at() and data/fish.json, see §6).

1. What a worldlet is

A worldlet is a small, complete world type: a ~320 m playable area with its own shape, water, climate, wildlife and features, generated from a seed like today's island. The current map becomes the first worldlet, "Valley": a mountain ring, a lake and a sea cliff. Others can differ in kind, not just in seed:

Worldlet Shape Water Twist
Valley (today) mountain ring + sea cliff lake wolves' den in the rim
Cove low hills down to a sandy bay sea + harbour, a stream sea fishing, a pier, tides later
Highlands plateau, cliffs, a tarn small cold lake snow most days, harsher nights
Marsh flat, reedy, pools many shallow pools fog, insects, bog iron later
Tutorial small (~160 m), calm, fixed seed one pond guided steps, a fenced rabbit meadow, named spots (docs/TUTORIAL_DESIGN.md)

A worldlet is data plus a few generator building blocks, not a new scene with its own code. That keeps the game's systems shared (building, survival, creatures, saving) and makes worldlets addable by addons later (§7).

2. The big decision: one worldlet per save, or linked worldlets?

A. One worldlet per save. New World asks for a name, a seed and a worldlet type. Simple, and it fits everything already built.

B. Linked worldlets in one save. Several worldlets form one world, and you travel between them (a mountain pass, a cave tunnel, a boat). Each keeps its own state. More exciting, but much more work, especially in M2: a server would have to simulate several places at once, or keep all players together.

Recommendation: build A first, with a save layout that allows B later. Each worldlet's state goes in its own file from day one (worlds/<id>/worldlets/<worldlet>.json), and the player's own state moves to worlds/<id>/player.json. Then B is "add travel and a second file", not "migrate every save".

3. Definition format

data/worldlets/<id>.json. Every number that's an @export on terrain.gd today moves here, plus the lists that are hard-coded:

{
  "id": "cove",
  "name": "Cove",
  "description": "Low hills running down to a sheltered bay.",
  "size": 320,
  "terrain": {
    "base": {"height_scale": 7.0, "octaves": 4},
    "boundary": [
      {"type": "mountains", "from_deg": 60, "to_deg": 300, "height": 50},
      {"type": "beach", "from_deg": 300, "to_deg": 60, "slope": 0.08}
    ],
    "features": [
      {"type": "cove", "angle_deg": 0, "width": 60},
      {"type": "stream", "from": "mountains", "to": "cove"}
    ]
  },
  "water": {"sea_level": -1.5, "lakes": []},
  "climate": {"cold_day_chance": 0.1, "snowline": 45, "day_length": 1200},
  "wildlife": {
    "budget": {"rabbit": 5, "deer": 3, "boar": 2},
    "wolves": {"den": "rim", "den_day": 3, "hunt_day": 5}
  },
  "places": [{"type": "pier", "at": "cove"}],
  "spawn": {"near": "cove", "facing": "sea"}
}

Generator building blocks (GDScript, shared by all worldlets): base hills, mountain rim, sea cliff, beach slope, cove/bay, lake basin, stream bed, plateau. A worldlet only picks and parameterises them, so it can't do anything a block can't.

4. What changes in the code

Most game code already asks the terrain generic questions: sample_height, sample_normal, inside_boundary and find_spawn. The work is in the places that know about this island's single lake and cliff. Today:

Assumption Used in Becomes
one lake at water_level player (swimming), creature, spawner, den, build_system, props, lake_fish, world world.water_at(x, z) (done for fishing)
lake_center / lake_radius lake_fish, world (views) the water bodies list
sea_angle_deg, coast_point props (sea stacks, cliff-top strip), world (views) the boundary segment list
snow_height props, spawner, weather climate.snowline
play_radius props, den boundary queries

The steps:

  1. Terrain pipeline. terrain.gd becomes a runner that stacks the chosen building blocks into the height grid. The grid, collision, sample_height and the boundary wall stay exactly as they are.
  2. A WorldInfo API on the world node, for everything outside terrain: water_at, water_bodies(), boundary_at(angle), features(type), climate. No system reads terrain exports directly any more.
  3. Water bodies. Each lake, pond and the sea is a body with a surface, shape and type. The water mesh is built per body. Swimming, the "too deep" check, animal steering and fishing all go through water_at.
  4. Props and places by feature. Scenery reads densities from the definition. The den, the sea stacks and (in Cove) the pier are "places" positioned by features, not hard-coded angles.
  5. Saves. meta.json gains "worldlet": "valley". Older saves default to Valley, so nothing breaks.
  6. New World screen. Pick a worldlet type (with a short description and a preview render), then name and seed as now.

5. The Cove and sea fishing

  • Beach boundary. Sand slopes gently into shallow water. You can wade and swim out until the water is deep, and then a current carries you back (the beach equivalent of the invisible wall, with a toast).
  • Harbour. A sheltered bay with a generated wooden pier. The pier is built from the building pieces, so it's "placed by the world" and can't be dismantled.
  • Sea fish in data/fish.json under "sea": mackerel (common), cod (bigger, deeper water), and maybe crab pots later. Casting from the pier reaches deep water. The fishing code needs no changes: it already asks water_at what it landed in and looks up that water's fish.
  • Later: tides (sea level shifting slowly), a raft or rowing boat, and driftwood on the beach.

6. Done already (groundwork)

  • world.water_at(x, z) returns {type, surface, depth} for the lake and the sea, and fishing uses it instead of terrain.water_level.
  • data/fish.json holds the fish per water type, bite timings and dawn/dusk weights. ItemDB.define_water() registers a water's fish, so an addon or a worldlet can add "sea" without code.

7. How it fits the roadmap

  • Before M2 (recommended): steps 1-6 for Valley alone, with no visible change, then the Cove. Doing the refactor before multiplayer means M2 replicates "worldlet id + seed + edits", not one hard-coded island.
  • M2: the server loads one worldlet per save (option A). Clients regenerate it from id + seed, which is the same determinism we rely on now.
  • M3 (addons): define_worldlet() takes the same JSON. Addons can only combine building blocks, so a worldlet addon can't run arbitrary code in the generator.
  • Option B (linked worldlets) fits after M2, once we know how the server handles players in different places.

8. Phasing and tests

Phase Delivers Done when
W0 terrain pipeline + WorldInfo API; Valley defined in JSON seed 1337 generates the same height grid as today (a hash test), and all 9 suites still pass
W1 worldlet picked on New World and saved in meta; old saves = Valley load an old save and a new Valley save; both play
W2 water bodies (several per world), beach boundary block, swim-back current a test worldlet with two ponds and a beach passes the boundary and terrain-edge tests
W3 Cove worldlet: bay, stream, pier place, sea fish you catch a mackerel off the pier
W4 (optional) Highlands / Marsh each passes the shared suites
W5 (after M2, optional) linked worldlets and travel walk through a pass from Valley into Cove and back

Every existing suite runs per worldlet (boundary, terrain edges, survival, building, wildlife, fishing), so a new worldlet can't ship with a hole in its edge or animals that spawn in the sea.

9. Questions for you

  1. A or B? One worldlet per save to start, or do you want travel between them early? (Recommendation: A, with the save layout ready for B.)
  2. Worldlet size: keep ~320 m, or let some be bigger?
  3. Difficulty per worldlet: should harsher worldlets (Highlands) bring wolves sooner, or keep the same grace period everywhere?
  4. Which second worldlet first: Cove (sea fishing, the beach you mentioned) is my suggestion.