Lights to place indoors (tallow candle, torch stand); docs brought up to date; the journey plan; sticky teleports in net tests; protocol 15

This commit is contained in:
Paul Bailey
2026-11-06 13:56:43 +00:00
parent 5f40fa3f7b
commit eaeb841c1d
15 changed files with 360 additions and 18 deletions

View File

@@ -16,7 +16,18 @@ addon SDK with guard rails.
## Status
See `docs/ROADMAP.md` for milestones and `docs/ARCHITECTURE.md` for the design.
Current milestone: **M0 — playable single-player world skeleton** (scaffolding on disk; needs Godot 4 installed to open & run).
**Emberwild 0.3.0** is released (download page and player guide:
https://emberwild-95k.pages.dev, builds on the Gitea releases). Built so far:
- M0-M1: the island, survival, crafting, building, weather, wildlife, hunting, fishing.
- M2: self-hosted multiplayer (Host Game, dedicated servers, rules, chat, admin).
- P1-P5 (`docs/PROGRESSION_DESIGN.md`): iron, stone building, the wolf moon and
alpha, the bear, crows and foxes, the barrow, hazards, the Old Stag. P6: a use
for every material, lights to place, dropping items, bag rows on the hotbar.
- T1, T2, T4 (`docs/TUTORIAL_DESIGN.md`): the tutorial with Old Tam (spoken),
and his tips in normal worlds.
- M4 so far: packaged builds, the update check, music, ambience, sound effects
and creature voices (`assets/audio/CREDITS.md`), the trailer and help pages.
Next: the worldlets and the journey between them (`docs/WORLDLETS_DESIGN.md`).
## Requirements
- **Godot 4.3+** (standard build; no C# needed — this project uses GDScript).

View File

@@ -964,7 +964,7 @@
"name": "Bear Fat",
"category": "resource",
"stack": 20,
"description": "Thick white fat. Burns long on a fire, and makes pemmican.",
"description": "Thick white fat. Burns long on a fire, and makes pemmican and tallow candles.",
"mesh": "bear_fat",
"fuel": 180
},
@@ -1216,6 +1216,61 @@
"plants": 28
}
}
},
{
"id": "tallow_candle",
"name": "Tallow Candle",
"category": "placeable",
"stack": 20,
"description": "Bear fat and a fibre wick. Set it on a floor or a step and it lights a small room, steadily, for good.",
"mesh": "tallow_candle",
"place": {
"piece": "lamp",
"footprint": [
0.2,
0.2
]
},
"lamp": {
"range": 7.0,
"energy": 1.2,
"height": 0.2,
"flicker": 0.08,
"flame": 0.022,
"color": [
1.0,
0.82,
0.6
]
}
},
{
"id": "torch_stand",
"name": "Torch Stand",
"category": "placeable",
"stack": 5,
"description": "A torch on a wooden stand. Lights a whole room or a doorway, and never goes out.",
"mesh": "torch_stand",
"place": {
"piece": "lamp",
"footprint": [
0.5,
0.5
]
},
"lamp": {
"range": 11.0,
"energy": 1.6,
"height": 1.45,
"flicker": 0.22,
"flame": 0.06,
"color": [
1.0,
0.74,
0.5
],
"shadows": true
}
}
]
}

View File

@@ -658,6 +658,29 @@
"berries": 3,
"bear_fat": 1
}
},
{
"id": "tallow_candle",
"output": "tallow_candle",
"count": 4,
"station": "",
"category": "furniture",
"inputs": {
"bear_fat": 1,
"fiber": 1
}
},
{
"id": "torch_stand",
"output": "torch_stand",
"count": 1,
"station": "",
"category": "furniture",
"inputs": {
"wood": 2,
"stick": 2,
"fiber": 2
}
}
]
}

View File

@@ -1,6 +1,26 @@
# Progression and challenges design
Status: **P1-P5 built** (iron, stone, adversaries, hazards, the Old Stag).
Status: **P1-P6 built** (iron, stone, adversaries, hazards, the Old Stag,
and uses for every material).
*As built in P6 (0.3.0, every material earns its place):*
- Clay jar (8 clay, fired at a campfire): 6 slots, food inside ages at a
third of the rate (`Inventory.keep_mult`, which moves the "rot" stamps).
- Bedroll (3 hide, 6 feathers, 4 fibre): a bed (group "bed", like the bed
everywhere a spawn bed is looked for) that needs no shelter, only no rain
or snow on it.
- Wolf-pelt rug (3 wolf pelts): a rug, so Cozy like the fur rug.
- Fang necklace (2 fangs, 1 alpha pelt): a "charm" carried anywhere in the
bag; hits whose cause names a wolf do 70% (`Actions.charm_mult`).
- Bear cloak (2 bear hide, 2 hide): +40 warmth, the warmest.
- Pemmican (2 cooked meat, 3 berries, 1 bear fat at a campfire, makes 3):
meat and greens, keeps 30 days.
- Rotten food is fishing bait: carried, bite waits x0.6, a bite uses one.
- Lights to place (after 0.3.0): tallow candle (bear fat and fibre, makes 4,
7 m) and torch stand (11 m, shadows), always lit (`lamp.gd`).
- `Q` drops what you hold (shift: one), at your feet, online too; `K`/`M`
move the hotbar up and down the bag's five rows (any slot can be held).
- `tests/test_items_more.gd` checks every material has a use.
*As built in P1:*
- Iron nuggets (70, in the scree under the rim), clay (60, on the lake

View File

@@ -85,8 +85,18 @@ phases M2.0-M2.7).
## M4 — Content & polish · ongoing
- More biomes, creatures, items, building pieces
- PvP / raiding rules, building damage
- Better UI, settings, controls
- Audio, music, weather
- Better UI, settings, controls. *Done so far:* volume sliders, tips and
update settings in the pause menu; `Q` drops items; `K`/`M` put any bag row
on the 1-8 keys (0.3.0).
- Audio, music, weather. *Done (0.2.0-0.2.1):* mood music (calm, night,
storm, danger, boss) that crossfades with what's happening, ambience (CC0
birdsong, crickets, owls, fire), sound effects for every action (Kenney
CC0 plus synthesised), creature voices, Old Tam's spoken lines (Kokoro).
Sources and licences: `assets/audio/CREDITS.md`; tools in `tools/audio/`
and `tools/voice/`.
- Help: the player guide on the download site (`tools/site/`), written
sections plus item, recipe, creature, fish and server-setting tables made
from the game's own data each build. The trailer (`tools/trailer/`).
- Packaged builds for Win/Mac/Linux (+ Mac signing/notarisation).
*First cut done:* `tools/export.sh` makes a universal macOS .app (ad-hoc
signed, not notarised) and a Windows x86_64 .exe + .pck (unsigned), zipped

View File

@@ -164,3 +164,104 @@ its edge or animals that spawn in the sea.
wolves sooner, or keep the same grace period everywhere?
4. **Which second worldlet first:** Cove (sea fishing, the beach you
mentioned) is my suggestion.
## 10. The journey: tunnelling out (proposal, Nov 2026)
A goal for the game, from Paul: once you have a good enough pick, you tunnel
through the mountain rim into the next, slightly harder worldlet, and now and
then go back to earlier ones for what only they have. This is option B
(linked worldlets) from §2, reached by digging rather than a path.
### The shape of it
- **A chain of worldlets**, each a little harder: Valley (today's island) →
Cove (sea, salt, mackerel) → Highlands (cold, deep snow, bigger packs) →
Marsh (bog iron, sickness, mists) → the Ember, the volcanic heart the game
is named for: the end of the journey.
- **The goal**, something to tell players on day 1: Old Tam talks of the
land beyond the mountains, where the fire in the earth still burns. Reach
the Ember and light the old beacon there. (Whether there's an ending or
it's open-ended is a question below.)
- **Gates:** each tunnel needs the best tool from the worldlet you're in.
The antler pickaxe (from the Old Stag) opens the Valley's tunnel; the
Highlands need a pick made from something found in the Cove, and so on.
Each worldlet has its own boss or trial, like the Old Stag.
### A different crossing each time (Paul, Nov 2026)
Players have to work out how to get from each worldlet to the next, and
it's a different way every time: tunnelling, climbing, burrowing, sailing.
Each crossing is that worldlet's puzzle, and what it needs comes from what
you've learned and gathered there:
| From → to | Crossing | Needs (a first idea) |
|---|---|---|
| Valley → Cove | **Tunnel** through the rim at a seam | the antler pickaxe, timber props, light |
| Cove → Highlands | **Sail** out of the bay and round the headland | a boat (planks, nails, a hide sail, rope), calm weather, food that keeps |
| Highlands → Marsh | **Climb** a frozen cliff | climbing picks and rope, warmth to last the climb (bear cloak, pemmican) |
| Marsh → Ember | **Burrow** under the old lava crust | a digging tool from bog iron, and a way through the heat and gas |
- **The hint, not the answer:** each worldlet shows its way out without
explaining it: a seam in the rock with old pick marks, a wrecked boat on
the beach, rope frayed on a cliff ledge. Old Tam (and his tips) nudge,
and the guide can keep it vague.
- **Coming back** uses the same crossing in reverse (the tunnel stays dug,
the boat stays moored, the rope stays fixed), so supply runs get easier
once you've made the way.
- **New systems this needs, one per crossing:** digging into a rim seam
(tunnel and burrow share most of it), a boat you build and steer on the
water, and climbing a marked face. Each is a phase of its own (J1 tunnel;
later the sail, the climb, the burrow).
### Tunnelling (the first crossing)
- **Where:** each worldlet's rim has one or two **seams**: cracked, darker
rock faces with a few loose stones at the foot, findable by exploring
(and Old Tam's tips). The rim stays a wall everywhere else.
- **How:** hit a seam with a good enough pick, many times. Each stretch dug
needs **timber props** (wood) or it falls back in, and it's dark in there:
candles, torch stands or a lantern. The tunnel is a short, built-for-it
cave (a few bends, maybe a side chamber with ore or a hazard), so it
stays hand-made rather than a hole through the terrain mesh, which the
heightmap can't do.
- **Through:** the far end opens onto the next worldlet. Walking out loads
it (a short fade, "The Cove"), with your bag, cloak and stats as they are.
### Why go back
- **Each worldlet has things the others lack.** Clay and salt only in the
Valley and Cove; little wood in the Highlands; no bears outside the
Valley (so bear fat for candles and pemmican is a supply run); a fish or
herb only one place has. Food that keeps (pemmican, salted and smoked
meat, clay jars) becomes what you carry between them.
- **Your bases stay** where you built them: each worldlet keeps its own
save file, so your Valley hut, chests and jars are waiting.
- **Time:** a worldlet you're not in stands still (as a server does with
nobody on), so a base you left doesn't starve or get raided.
### Building it (phases)
| Phase | Delivers | Done when |
|---|---|---|
| **W0-W2** | the worldlet groundwork above (terrain pipeline, worldlet in the save, several water bodies) | as §8 |
| **J1** | seams in the rim, digging with props and light, a tunnel scene, travel between two worldlets in single-player, per-worldlet save files | dig through from the Valley, walk to the Cove, walk back to your hut |
| **W3** | the Cove (§5) as the second worldlet | its suites pass, and its tunnel back works |
| **J2** | gates and supply needs: tool tiers per tunnel, resources only some worldlets have | a test bot can't open the second tunnel without the Cove's pick |
| **J3** | multiplayer travel (see below) | two players in different worldlets on one server, or the rule we choose |
| **J4** | Highlands, Marsh, the Ember and its ending | the journey can be finished |
### Multiplayer: the hard part
A server today runs one island. Options:
- **Everyone together:** travel needs every player at the tunnel (like the
sleep rule). Simplest; one worldlet runs at a time.
- **Spread out:** the server runs each worldlet that has someone in it.
The most freedom, and the most work and CPU.
- **Party leader:** whoever goes through brings the others with a vote.
### Questions for you
1. **The ending:** a real ending at the Ember (light the beacon, a last
boss, the credits) or open-ended (the journey is the goal, keep living)?
2. **The crossings:** do the four above fit (tunnel, sail, climb, burrow),
and in that order? Should a worldlet have more than one way out (a hard
one and a long one)? And for the tunnel: only at marked seams, or
anywhere in the rim (more freedom, harder to make good tunnels)?
3. **Multiplayer:** everyone together, spread out, or a party vote?
4. **How much harder** each worldlet: stronger animals and weather only, or
new rules too (thirst in the Ember, sickness in the Marsh)?
5. **Order:** Cove second (sea fishing, gentle) as planned, or straight
into the Highlands for a harder step?

64
scripts/building/lamp.gd Normal file
View File

@@ -0,0 +1,64 @@
extends "res://scripts/building/piece.gd"
## A light to place indoors (or anywhere): a tallow candle or a torch stand.
## Always lit, no fuel. Its item's "lamp" says how: {"range", "energy",
## "height" (of the flame), "flicker" (0 = steady), "color"}.
## Flames are a small additive glow; the light itself is an OmniLight3D.
var _light: OmniLight3D
var _flame: MeshInstance3D
var _noise := FastNoiseLite.new()
var _t := 0.0
var _base := 1.0
var _flicker := 0.0
var _h := 0.5
func _build() -> void:
add_to_group("lamp")
var l: Dictionary = def.get("lamp", {})
_base = float(l.get("energy", 1.0))
_flicker = float(l.get("flicker", 0.2))
_h = float(l.get("height", 0.5))
_noise.seed = int(get_instance_id() % 1000)
_noise.frequency = 2.0
_t = randf() * 100.0
_light = OmniLight3D.new()
var c: Array = l.get("color", [1.0, 0.7, 0.4])
_light.light_color = Color(c[0], c[1], c[2])
_light.light_energy = _base
_light.omni_range = float(l.get("range", 6.0))
_light.omni_attenuation = 1.3
_light.shadow_enabled = bool(l.get("shadows", false))
_light.position = Vector3(0, _h + 0.05, 0)
add_child(_light)
# The flame: a small glowing teardrop at the wick.
var m := StandardMaterial3D.new()
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
m.blend_mode = BaseMaterial3D.BLEND_MODE_ADD
m.albedo_color = Color(1.0, 0.72, 0.32, 0.85)
var s := SphereMesh.new()
var fr := float(l.get("flame", 0.03))
s.radius = fr
s.height = fr * 3.2
s.radial_segments = 8
s.rings = 4
s.material = m
_flame = MeshInstance3D.new()
_flame.mesh = s
_flame.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
_flame.position = Vector3(0, _h, 0)
add_child(_flame)
func display_name() -> String:
return String(def.get("name", "Light"))
func interact_prompt(_player: Node) -> String:
return "%s (X to take it up)" % display_name()
func _process(delta: float) -> void:
if _flicker <= 0.0 or _light == null:
return
_t += delta
var f := _noise.get_noise_1d(_t * 9.0)
_light.light_energy = _base * (1.0 + f * _flicker)
_flame.scale = Vector3.ONE * (1.0 + f * _flicker * 0.8)

View File

@@ -118,6 +118,8 @@ static func build(key: String) -> ArrayMesh:
"pelt_rug": m = _rug(WOLF_FUR, 0.85)
"bear_cloak": m = _cloak(BEAR_FUR)
"clay_jar": m = _jar()
"tallow_candle": m = _candle()
"torch_stand": m = _torch_stand()
"bedroll": m = _bedroll()
"fang_necklace": m = _necklace()
"pemmican": m = _pebble(331, Color(0.42, 0.24, 0.16), Vector3(0.1, 0.05, 0.07), 0.15)
@@ -1033,6 +1035,29 @@ static func _jar() -> ArrayMesh:
_box(st, Vector3(0, 0.63, 0), Vector3(0.08, 0.04, 0.05), PLANK_DARK)
return P._commit(st)
## A squat tallow candle on a clay dish; the wick's top is at y = 0.2
## (lamp.gd puts the flame there).
static func _candle() -> ArrayMesh:
var st := P._begin()
var r := _rng(359)
P._cylinder(st, Vector3(0, 0.0, 0), Vector3(0, 0.02, 0), 0.08, 0.09, 8, CLAY_FIRED, r, -1.0, P._mul(CLAY_FIRED, 1.1))
P._cylinder(st, Vector3(0, 0.02, 0), Vector3(0, 0.16, 0), 0.04, 0.036, 8, Color(0.93, 0.89, 0.74), r, -1.0, Color(0.96, 0.93, 0.8))
P._cylinder(st, Vector3(0, 0.16, 0), Vector3(0, 0.19, 0), 0.004, 0.003, 4, ASH, r)
return P._commit(st)
## A torch in a wooden stand: three splayed feet, a post, the head at 1.4 m
## (lamp.gd's flame sits just above it).
static func _torch_stand() -> ArrayMesh:
var st := P._begin()
var r := _rng(367)
for i in range(3):
var a := TAU * i / 3.0
P._cylinder(st, Vector3(cos(a) * 0.28, 0.0, sin(a) * 0.28), Vector3(0, 0.45, 0), 0.03, 0.035, 5, _board(r, PLANK_DARK), r)
P._cylinder(st, Vector3(0, 0.0, 0), Vector3(0, 1.3, 0), 0.04, 0.035, 6, _board(r), r)
_box(st, Vector3(0, 0.5, 0), Vector3(0.12, 0.05, 0.12), BINDING)
P._cylinder(st, Vector3(0, 1.28, 0), Vector3(0, 1.42, 0), 0.05, 0.06, 6, CLOTH, r, -1.0, ASH)
return P._commit(st)
## A hide bedroll: a flat mat with its rolled-up end and a feather pillow.
static func _bedroll() -> ArrayMesh:
var st := P._begin()

View File

@@ -32,7 +32,7 @@ signal clock(day: int, time: float) ## the server's time of day
const Identity = preload("res://scripts/net/identity.gd")
const ServerConfig = preload("res://scripts/net/server_config.gd")
const PROTOCOL := 14 ## 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear; 9: crows and foxes; 10: the barrow (P3); 11: food spoilage, lightning (P4); 12: the standing stones and the Old Stag (P5); 13: sounds sent to players nearby (sfx.gd); 14: dropping items, any bag row in hand, new items (jar, bedroll, necklace, bear cloak, rug, pemmican)
const PROTOCOL := 15 ## 3: shared world; 4: the server decides; 5: wildlife; 6: rules and chat (M2.6); 7: the alpha, broken doors; 8: the bear; 9: crows and foxes; 10: the barrow (P3); 11: food spoilage, lightning (P4); 12: the standing stones and the Old Stag (P5); 13: sounds sent to players nearby (sfx.gd); 14: dropping items, any bag row in hand, new items (jar, bedroll, necklace, bear cloak, rug, pemmican); 15: placed lights (candle, torch stand)
const DEFAULT_PORT := 24242
const HANDSHAKE_TIMEOUT := 10.0
const JOIN_TIMEOUT := 20.0 ## includes the server building its terrain on first join

View File

@@ -31,6 +31,7 @@ const PIECES := {
"hearth": preload("res://scripts/building/hearth.gd"),
"rug": preload("res://scripts/building/rug.gd"),
"jar": preload("res://scripts/building/jar.gd"),
"lamp": preload("res://scripts/building/lamp.gd"),
"stonecutter": preload("res://scripts/building/stonecutter.gd"),
}
const BuildPiece = preload("res://scripts/building/build_piece.gd")

View File

@@ -174,6 +174,19 @@ func _body() -> void:
break
check(player.survival.diet.meat > d0.meat and player.survival.diet.plants > d0.plants, "it counts as meat and greens")
print("lights indoors")
inv.add("bear_fat", 1)
inv.add("fiber", 1)
check(A.craft(player, "tallow_candle") and inv.count("tallow_candle") == 4, "4 tallow candles from a lump of bear fat and some fibre, by hand")
for id in ["tallow_candle", "torch_stand"]:
var lamp := _place(id, here + Vector3(-2, 0, 3))
var lights := lamp.find_children("*", "OmniLight3D", false, false)
check(lamp.is_in_group("lamp") and lights.size() == 1 and (lights[0] as OmniLight3D).omni_range >= 6.0,
"a %s, placed, gives a light (%.0f m)" % [DB.get_item(id).name.to_lower(), (lights[0] as OmniLight3D).omni_range if not lights.is_empty() else 0.0])
player.global_position = lamp.global_position + Vector3(1.0, 0, 0)
check(A.dismantle(player, lamp) and inv.count(id) >= 1, "and X takes it back up")
player.global_position = here
print("bait")
var B: Script = load("res://scripts/fishing/bobber.gd")
var bob: Node3D = B.new()

View File

@@ -233,11 +233,15 @@ func _toast_has(s: String) -> bool:
return true
return false
## Teleports Alice and makes sure it sticks (test_net_life.gd's _tp says why).
func _tp(at: Vector3) -> void:
for _try in range(3):
PN.ask("debug_tp", [at + Vector3(0, 0.05, 0)])
await _until(func(): return player.global_position.distance_to(at) < 1.0, 2.0)
player.set_physics_process(false)
await _sleep(0.3)
await _sleep(0.5)
if player.global_position.distance_to(at) < 1.0:
return
func _give(id: String, n: int) -> int:
var before: int = player.inventory.count(id)

View File

@@ -327,11 +327,18 @@ func _wolves() -> void:
# --- helpers --------------------------------------------------------------------
## Teleports Alice (the server moves her) and makes sure it sticks: a last
## position update her game sent from before the move can reach the server
## after it, look like a jump, and get her moved back ("too fast"). So check
## she's still there a moment later, and go again if not.
func _tp(at: Vector3) -> void:
for _try in range(3):
PN.ask("debug_tp", [at + Vector3(0, 0.05, 0)])
await _until(func(): return player.global_position.distance_to(at) < 1.0, 2.0)
player.set_physics_process(false)
await _sleep(0.3)
await _sleep(0.5)
if player.global_position.distance_to(at) < 1.0:
return
func _give(id: String, n: int) -> int:
var before: int = player.inventory.count(id)

View File

@@ -321,11 +321,15 @@ func _bob_slot(id: String) -> int:
return i
return -1
## Teleports Alice and makes sure it sticks (test_net_life.gd's _tp says why).
func _tp(at: Vector3) -> void:
for _try in range(3):
PN.ask("debug_tp", [at + Vector3(0, 0.05, 0)])
await _until(func(): return player.global_position.distance_to(at) < 1.0, 2.0)
player.set_physics_process(false)
await _sleep(0.3)
await _sleep(0.5)
if player.global_position.distance_to(at) < 1.0:
return
func _give(id: String, n: int) -> int:
var before: int = player.inventory.count(id)

View File

@@ -199,6 +199,10 @@ Stone loses much less support going up, so it builds tall. Wood can sit on stone
You're **Sheltered** with a roof overhead and walls in at least six of eight directions. A roof alone keeps the rain off you and your fire. A campfire works fine indoors. The **stone hearth** warms a room half as wide again as a campfire, its chimney keeps the rain out, and it cooks. A **fur rug** in a sheltered room makes you Cozy.
### Lights
Indoors at night it's dark unless something is burning. A **tallow candle** (a lump of bear fat and a fibre, by hand, makes 4) lights a small room with a steady glow for about 7 m. A **torch stand** (2 wood, 2 sticks, 2 fibre, by hand) flickers like a torch and lights about 11 m: a whole hut or a doorway. Both stay lit for good, need no fuel and give no warmth. `X` takes them back up.
### Beds
`E` on a bed makes it your spawn point. Once it's yours, `E` sleeps until 06:00, if it's between 19:30 and 05:30, the bed is sheltered, and no wolves are prowling within 40 m. A **bedroll** (3 hide, 6 feathers, 4 fibre) works the same but needs no shelter: you can sleep under the open sky, as long as it isn't raining or snowing. While you sleep, fires burn, kilns and smelters work, things regrow and food spoils. You wake dry, warm, full of stamina, Rested and a little hungrier, and the game saves.