Health survives a door, and experience needs you to be there

Arriving anywhere set health to maximum, so a run cost nothing: walk in,
take what you could, walk out, walk back in whole. Current health now
lives on the character record for the same reason the inventory does --
SimWorld builds a blank player on every instance change, so anything that
must survive a door has to be written down -- and it is banked at every
transfer, every disconnect, and on every item transaction, since a potion
moves an item and hit points in one action.

The hub is where that damage is paid off, at 10% of maximum per second:
about ten seconds from nothing, fast enough not to be a chore and slow
enough to be a decision before walking back in. The dungeon rate is
unchanged, so waiting one out is still not a plan. Regeneration is a
property of the world now rather than a constant.

adopt() no longer touches current health. It is called mid-session by
on_choose_upgrade, and reading health from the record there would snap a
player back to whatever was last written to disk; recompute_max_hp still
clamps downwards so Glass Cannon takes the hit points above the new
ceiling with it. ServerRuntime._adopt_character stays the one place a
record becomes a live player.

Experience is still undivided, but now only among players within
XP_AWARD_RADIUS of the kill -- ten percent past what a player can see, so
anyone who watched it die is inside and someone parked in the entrance
room is not. The death events carry a position for this, and deliberately
do not put it on the wire: encode_events still writes the id alone.

diag_progression covers the whole chain end to end -- wounded in a
dungeon, escaped to the hub with the wound intact, healing there, and
walking back in still hurt -- because a unit test cannot transfer a peer.
Both halves were mutation-tested against it; the unit suite alone missed
the arrival heal entirely.

The Proving Grounds is recorded in the roadmap as a debugging tool that
does not ship: it pays full experience for a boss with 8% health.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
claudeandClaude Opus 5 committed 2026-09-07 21:48:20 +02:00
1 parent 54086b5604
commit 4aeb6d229d
13 files changed
+405 -28

No files matched your search

+16
View File
@@ -64,6 +64,12 @@ const SPAWN_GRACE_TICKS := 120 # 2 seconds
## simpler to reason about than a timer players have to learn, and at this rate
## it cannot outpace anything that is actually shooting at you.
const HP_REGEN_PERCENT_PER_SEC := 0.5
## What the hub heals at instead. Arriving anywhere used to snap you to full,
## which made health free: enter a dungeon, take what you could, leave, walk
## back in whole. Recovering it takes ten seconds of standing in the hub now,
## so the run you just left cost you something -- and the cost is time in the
## one place nothing is shooting at you, not a resource to manage.
const LOBBY_HP_REGEN_PERCENT_PER_SEC := 10.0
## A downed player cannot leave for the hub until this has elapsed. Enforced in
## the simulation rather than only by grey-ing out the button, because a
@@ -193,6 +199,16 @@ const DUNGEON_CLEARED_EXIT_TICKS := 1800 # 30 seconds
const FOG_VIEW_RADIUS := 460.0
# --- Interest management ----------------------------------------------------
## How near a kill you have to be to be paid for it. Ten percent beyond what a
## player can see, so someone who watched it die is always inside and someone
## parked at the entrance never is. Experience stays undivided among everyone
## who qualifies (docs/DECISIONS.md) -- this decides who was there, not how much
## each of them gets.
##
## Worth knowing: an upgraded shot outranges this several times over, so a kill
## made far enough away that you cannot see it pays nothing.
const XP_AWARD_RADIUS := FOG_VIEW_RADIUS * 1.1
## Actors further than this from a player are not sent to that player at all.
## Comfortably beyond FOG_VIEW_RADIUS so nothing visibly pops in at the edge of
## sight, but far short of a dungeon, so a modified client cannot read enemy
+4
View File
@@ -50,6 +50,10 @@ static func make_lobby(instance_id: int) -> Instance:
# Nothing here to escape from, and completing the channel in the hub means
# transferring to the hub -- see SimWorld.escape_enabled.
inst.world.escape_enabled = false
# The hub is where a run's damage is paid off. Arriving used to heal you to
# full, which made health free between runs; ten seconds of standing here
# does the same job at a price.
inst.world.regen_percent_per_sec = SimConfig.LOBBY_HP_REGEN_PERCENT_PER_SEC
inst.state = State.ACTIVE
# A single inert practice target so players can feel out the gun before
# committing to a run.
+17
View File
@@ -28,6 +28,17 @@ var died_unix: int = 0
## bags, and so a server restart does not quietly confiscate everyone's potions.
var inventory: Array[StringName] = []
## Current health, or [constant HP_FULL] for "however much this character can
## have". Stored for the same reason the inventory is: SimWorld builds a blank
## player on every instance change, so anything that must survive walking
## through a door has to live on the record. Without it, entering and leaving
## was a free heal, and health cost nothing between runs.
##
## HP_FULL rather than a number so a save written before this existed, and a
## character that has never been hurt, both load as whole rather than as dead.
const HP_FULL := -1
var hp: int = HP_FULL
## Upgrades taken, in the order they were taken. Duplicates are meaningful --
## two Split Shots really is two splits -- so this is a list, not a set.
##
@@ -140,6 +151,7 @@ func to_dict() -> Dictionary:
# Items.ORDER being appended to, and a human editing it should be able
# to tell what a character is carrying.
"inventory": _inventory_ids(),
"hp": hp,
"upgrades": _name_list(upgrades),
"pending_choices": pending_choices,
"offer": _name_list(offer),
@@ -180,6 +192,11 @@ static func from_dict(d: Dictionary) -> Character:
var carried_id := StringName(String(raw))
carried.append(carried_id if Items.get_def(carried_id) != null else Items.NONE)
c.set_inventory(carried)
# Anything unreadable, negative or absent means full. The failure that
# matters is a save loading as a character with no health left, which under
# permadeath is not something a player can recover from.
var saved_hp := int(d.get("hp", HP_FULL))
c.hp = saved_hp if saved_hp > 0 else HP_FULL
# Unknown upgrade ids are dropped rather than kept as dead entries, so a
# save from a build with an upgrade this one lacks still produces coherent
# stats instead of a phantom that counts toward the +5% and does nothing.
+10
View File
@@ -132,6 +132,16 @@ func set_inventory(account_id: int, character_id: String, items: Array[StringNam
save()
## Bank a character's current health. Deferred like the inventory: it changes
## every tick a player is regenerating, and the server flushes once a tick.
func set_health(account_id: int, character_id: String, hp: int) -> void:
var c := get_character(account_id, character_id)
if c == null:
return
c.hp = maxi(hp, 1)
save_deferred()
## Award level-up choices and, if nothing is on the table yet, roll one.
##
## The offer is rolled HERE and stored, not generated on demand when the screen
+52 -10
View File
@@ -119,9 +119,11 @@ func _dispatch_events(inst: Instance) -> void:
for ev in events:
match int(ev["t"]):
SimEvent.Type.ENEMY_DIED:
_award_kill(inst, Progression.xp_for_enemy(StringName(ev.get("def", ""))))
_award_kill(inst, Progression.xp_for_enemy(StringName(ev.get("def", ""))),
ev.get("pos", Vector2.ZERO))
SimEvent.Type.BOSS_DIED:
_award_kill(inst, Progression.xp_for_boss(StringName(ev.get("def", ""))))
_award_kill(inst, Progression.xp_for_boss(StringName(ev.get("def", ""))),
ev.get("pos", Vector2.ZERO))
SimEvent.Type.ITEM_PICKED_UP, SimEvent.Type.ITEM_USED, \
SimEvent.Type.ITEM_DROPPED:
# The simulation moved items between the ground and a bag; the
@@ -198,6 +200,7 @@ func on_peer_disconnected(peer_id: int) -> void:
% [peer_id, peer_names.get(peer_id, "?"), inst.id])
_broadcast_roster()
return
_bank_health(inst, peer_id)
inst.remove_peer(peer_id)
_forget_peer(peer_id)
GameLog.info("server", "peer %d disconnected" % peer_id)
@@ -218,6 +221,9 @@ func _forget_peer(peer_id: int) -> void:
## the dungeon; with no persistence yet, "safely back in the hub" and "gone" are
## the same outcome, so it is simply removed.
func _release_linkdead(peer_id: int, inst: Instance) -> void:
# Whatever the channel left it on. Pulling the plug at one hit point must
# not be a way to come back whole.
_bank_health(inst, peer_id)
inst.remove_peer(peer_id)
GameLog.info("server", "peer %d released from instance %d after drop"
% [peer_id, inst.id])
@@ -348,6 +354,7 @@ func _enter_world_as(peer_id: int, c: Character) -> void:
var account: int = peer_accounts[peer_id]
var previous := instance_of(peer_id)
if previous != null:
_bank_health(previous, peer_id)
previous.remove_peer(peer_id)
peer_characters[peer_id] = c.id
peer_names[peer_id] = c.display_name
@@ -505,15 +512,19 @@ func _adopt_character(peer_id: int, inst: Instance) -> void:
if c == null or p == null:
return
p.adopt(c)
# Arriving anywhere is a fresh start. adopt() only clamps health down to the
# new ceiling, which would leave a character whose upgrades RAISED it
# arriving wounded for no reason.
p.hp = p.max_hp
# Health comes off the record, not from a full bar. Arriving used to heal
# you outright, which meant a run cost nothing: walk in, take what you can,
# walk out, walk back in whole. It is paid off in the hub instead, which
# regenerates fast (SimConfig.LOBBY_HP_REGEN_PERCENT_PER_SEC) but not
# instantly. HP_FULL covers a new character and a save written before health
# was kept; the clamp covers upgrades that moved the ceiling either way.
p.hp = p.max_hp if c.hp == Character.HP_FULL else clampi(c.hp, 1, p.max_hp)
func _transfer(peer_id: int, to: Instance) -> void:
var from := instance_of(peer_id)
if from != null:
_bank_health(from, peer_id)
from.remove_peer(peer_id)
_place(peer_id, to)
_broadcast_roster()
@@ -618,16 +629,26 @@ func _stream_map(peer_id: int, inst: Instance) -> void:
NetCodec.encode_map_chunks(inst.world.map, batch))
## Experience is shared by everyone alive in the instance, undivided. Splitting
## it would make bringing a friend cost you progress, which is the opposite of
## what a co-op game wants; the hub roster exists to help people group up.
func _award_kill(inst: Instance, amount: int) -> void:
## Experience is shared, undivided, by everyone alive and near enough to
## [param at] to have been part of it.
##
## Undivided is the settled decision: splitting it would make bringing a friend
## cost you progress, which is the opposite of what a co-op game wants. The
## radius is a separate question -- who was there -- and it exists because
## without one a player parked at the entrance collected full boss experience
## for the whole run. SimConfig.XP_AWARD_RADIUS is ten percent past what a
## player can see, so anyone who watched it die is inside it and anyone who
## could not possibly have known is not.
func _award_kill(inst: Instance, amount: int, at: Vector2) -> void:
if amount <= 0 or inst.kind != Protocol.InstanceKind.DUNGEON:
return
var reach_sq := SimConfig.XP_AWARD_RADIUS * SimConfig.XP_AWARD_RADIUS
for peer in inst.peers:
var p: SimPlayer = inst.world.players.get(peer)
if p == null or not p.alive:
continue
if p.pos.distance_squared_to(at) > reach_sq:
continue
_grant_xp(peer, amount)
@@ -637,6 +658,23 @@ func _award_kill(inst: Instance, amount: int) -> void:
## point of persisting an inventory is that a crash between "picked it up" and
## "wrote it down" must not be a way to lose an item -- or, far worse, a way to
## duplicate one.
## Copy a player's current health back onto its character record.
##
## Called wherever the [SimPlayer] is about to stop existing -- every transfer,
## every disconnect -- because SimWorld builds a blank one on the other side and
## whatever was not written down is gone. Also called on item transactions, so
## drinking a potion is banked with the potion that was spent.
func _bank_health(inst: Instance, peer_id: int) -> void:
var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT)
var character_id: String = peer_characters.get(peer_id, "")
if account == AuthProvider.NO_ACCOUNT or character_id.is_empty():
return
var p: SimPlayer = inst.world.players.get(peer_id)
if p == null or not p.alive:
return # a dead character is retired; its health is not a thing anymore
store.set_health(account, character_id, p.hp)
func _persist_inventory(inst: Instance, peer_id: int) -> void:
var account: int = peer_accounts.get(peer_id, AuthProvider.NO_ACCOUNT)
var character_id: String = peer_characters.get(peer_id, "")
@@ -649,6 +687,10 @@ func _persist_inventory(inst: Instance, peer_id: int) -> void:
# to re-serialise every account on the server. ServerRuntime flushes once at
# the end of the tick.
store.set_inventory(account, character_id, p.inventory, true)
# A potion moves an item and hit points in the same action; banking one
# without the other would let a restart hand the potion back.
if p.alive:
store.set_health(account, character_id, p.hp)
func _grant_xp(peer_id: int, amount: int) -> void:
+20 -5
View File
@@ -118,6 +118,16 @@ func can_request_respawn() -> bool:
## Adopt a character's stats. Called when a player picks or switches character.
## Inventory comes along: it is stored on the character, so swapping in the hub
## swaps bags too rather than handing one character another's potions.
## Adopt a character's stats. Called when a player picks or switches character,
## and again whenever an upgrade changes the numbers.
##
## Deliberately does NOT set current health. It is called mid-session by
## on_choose_upgrade, and reading health from the record there would snap a
## player back to whatever was last written to disk. recompute_max_hp() still
## clamps downwards, because Glass Cannon halving your ceiling has to take the
## hit points above it with them. Where current health comes from on arrival is
## ServerRuntime._adopt_character, which is the one place a record becomes a
## live player.
func adopt(c: Character) -> void:
character_id = c.id
display_name = c.display_name
@@ -126,7 +136,6 @@ func adopt(c: Character) -> void:
colour = c.colour
stats = PlayerStats.build(c.upgrades)
recompute_max_hp()
hp = mini(hp, max_hp)
set_inventory(c.inventory)
@@ -187,13 +196,19 @@ func heal_percent(percent: float) -> int:
return hp - before
## One tick of passive healing. Returns true if the visible hit points changed,
## so the caller can decide whether anything needs announcing.
func regenerate() -> bool:
## One tick of passive healing at [param percent_per_sec] of maximum health.
## Returns true if the visible hit points changed, so the caller can decide
## whether anything needs announcing.
##
## The rate is a property of where you are standing rather than a constant: the
## hub heals far faster than a dungeon does, because recovering between runs
## should take a moment rather than a resource, and recovering DURING one
## should not be possible at all by waiting.
func regenerate(percent_per_sec: float = SimConfig.HP_REGEN_PERCENT_PER_SEC) -> bool:
if not alive or hp >= max_hp:
regen_carry = 0.0
return false
regen_carry += float(max_hp) * (SimConfig.HP_REGEN_PERCENT_PER_SEC / 100.0) \
regen_carry += float(max_hp) * (percent_per_sec / 100.0) \
* SimConfig.TICK_DELTA
if regen_carry < 1.0:
return false
+12 -3
View File
@@ -54,6 +54,9 @@ var spawn_grace_ticks: int = 0
## for as long as the key is held. A world you can leave is the default; the hub
## is the one that opts out.
var escape_enabled: bool = true
## Passive healing here, as a percentage of maximum health per second. The hub
## overrides it -- see SimConfig.LOBBY_HP_REGEN_PERCENT_PER_SEC.
var regen_percent_per_sec: float = SimConfig.HP_REGEN_PERCENT_PER_SEC
var _next_actor_id: int = 1
var _ctx := EmitContext.new()
@@ -217,7 +220,7 @@ func step() -> void:
func _step_players() -> void:
for p in players.values():
p.regenerate()
p.regenerate(regen_percent_per_sec)
if p.spawn_grace > 0:
p.spawn_grace -= 1
if p.fire_cooldown > 0:
@@ -862,7 +865,12 @@ func _damage_enemy(e: SimEnemy, amount: int, silent: bool = false) -> void:
_drop_loot(e.def.loot, e.pos)
# The def id rides along so the instance layer can score it without
# looking up an actor that is about to stop existing.
events.append({"t": SimEvent.Type.ENEMY_DIED, "id": e.id, "def": String(e.def.id)})
# "pos" is read by ServerRuntime to decide who was close enough to be
# paid, and is deliberately not in the wire format -- encode_events
# writes the id and nothing else. A client has no use for it, and every
# field that does not go out is one that cannot be mined.
events.append({"t": SimEvent.Type.ENEMY_DIED, "id": e.id,
"def": String(e.def.id), "pos": e.pos})
## Returns the damage actually applied, after the phase's armour multiplier, so
@@ -880,7 +888,8 @@ func _damage_boss(amount: int, silent: bool = false, scale: bool = true) -> int:
if boss.hp <= 0:
boss.alive = false
_drop_loot(boss.def.loot, boss.pos)
events.append({"t": SimEvent.Type.BOSS_DIED, "def": String(boss.def.id)})
events.append({"t": SimEvent.Type.BOSS_DIED, "def": String(boss.def.id),
"pos": boss.pos})
return applied