Skip to content

Commit 29f1097

Browse files
hyperpolymathclaude
andcommitted
docs(lessons): add PlayerHP case study (aspect-decomposition + mut-vs-State)
Adds docs/guides/lessons/migrations/idaptik-player-hp.adoc — third migration case study, design-translated against AffineScript v0.1.0 following the Float→Int convention from idaptik-hitbox. Why this file: PlayerHP.res is a six-field mutable record bundling three orthogonal concerns (HP, i-frames, knockback) under one type. It stress-tests the playbook's central mut-vs-State decision criterion on a file with no external dependencies but interesting state — the gap between hitbox (pure leaf, no state) and UserSettings (heavy dependencies, design-only). The translation: - Fissions the monolithic record into three nominally distinct aspect structs (HP, IFrames, Knockback), composed in a parent Player. - Chooses `mut Player` over `State[Player]` effect because the mutators all run inside one tick under one owner — coupling is local to the frame. - Same-typed labelled args (~fromX, ~playerX) become a Pos struct, per the hitbox lesson's "labels-want-to-be-types" rule. - Float→Int with documented scaling (HP in tenths, timers in milliseconds) so this can compile against v0.1.0. Status is "design-translated, toolchain run pending" — the steps to verify are in the Reproducibility section. Honest about not having fabricated compiler output. Gap surfaced: aspect-decomposition (record with N orthogonal aspects bundled into one type) is the central pattern of game-state migration but is not named explicitly in the playbook's source-pattern index. The cardinal rule and the file-buffer anti-pattern point at it but a ReScript-table row would make it discoverable. Proposed for a v1.2 follow-up. Updates the playbook's lessons index to reference all three entries. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b11555e commit 29f1097

2 files changed

Lines changed: 329 additions & 1 deletion

File tree

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell (hyperpolymath)
3+
= Migration Lesson: idaptik `PlayerHP.res``PlayerHP.affine`
4+
:revdate: 2026-05-02
5+
:icons: font
6+
:source-highlighter: rouge
7+
8+
[NOTE]
9+
====
10+
**Status: design-translated, toolchain run pending.** The translation is
11+
written to be compilable against AffineScript v0.1.0 (Int arithmetic, no
12+
external module imports), following the same Float→Int convention
13+
xref:idaptik-hitbox.adoc[idaptik-hitbox] established. A Reproducibility
14+
block matching hitbox's will be filled in by the next translator who runs
15+
this through `affinescript-host-shim.sh`.
16+
17+
Picked because it stress-tests the migration playbook's central decision —
18+
*`mut` vs `State` effect* — on a record with three orthogonal aspects
19+
bundled into one struct. Unlike
20+
xref:idaptik-user-settings.adoc[idaptik-user-settings], PlayerHP has no
21+
external dependencies; unlike xref:idaptik-hitbox.adoc[idaptik-hitbox], it
22+
has interesting state.
23+
====
24+
25+
== The original (88 lines, ReScript)
26+
27+
[source,rescript]
28+
----
29+
type t = {
30+
mutable current: float,
31+
mutable max: float,
32+
mutable invincibleTimer: float,
33+
mutable knockbackVelX: float,
34+
mutable knockbackVelY: float,
35+
mutable knockbackTimer: float,
36+
}
37+
38+
let make = (~con: float): t => {
39+
let maxHP = 80.0 +. 20.0 *. (con /. 100.0)
40+
{ current: maxHP, max: maxHP, invincibleTimer: 0.0,
41+
knockbackVelX: 0.0, knockbackVelY: 0.0, knockbackTimer: 0.0 }
42+
}
43+
44+
let isInvincible = (hp: t): bool => hp.invincibleTimer > 0.0
45+
let isAlive = (hp: t): bool => hp.current > 0.0
46+
47+
let takeDamage = (hp: t, ~amount, ~fromX, ~playerX): unit => {
48+
if !isInvincible(hp) {
49+
hp.current = Math.max(0.0, hp.current -. amount)
50+
hp.invincibleTimer = Knockback.iFrames
51+
let dx = playerX -. fromX
52+
let direction = if dx >= 0.0 { 1.0 } else { -1.0 }
53+
hp.knockbackVelX = direction *. Knockback.speed
54+
hp.knockbackVelY = -80.0
55+
hp.knockbackTimer = Knockback.duration
56+
}
57+
}
58+
59+
let update = (hp: t, ~dt): (float, float) => { /* decay timers, return knockback velocity */ }
60+
----
61+
62+
A single mutable record carrying three orthogonal concerns: **health**
63+
(`current`, `max`), **invincibility** (`invincibleTimer`), and
64+
**knockback** (`knockbackVelX`, `knockbackVelY`, `knockbackTimer`). Six
65+
mutable fields; one type. The labelled args (`~con`, `~amount`, `~fromX`,
66+
`~playerX`, `~dt`) are also doing work the type system isn't.
67+
68+
== The decision the playbook forces you to make
69+
70+
The playbook's
71+
xref:../../migration-playbook.adoc#decision-criteria[`mut` vs `State`
72+
effect] criterion gives two candidates:
73+
74+
. **`mut PlayerHP`** — caller owns the value, mutation is local to each
75+
function call, the HP record is threaded through call sites that need
76+
it.
77+
. **`State[Player]` effect** — the HP record lives inside an effect
78+
handler, callers say `Player.take_damage(15)` without holding a
79+
reference.
80+
81+
But there's a third candidate the playbook doesn't name explicitly: it
82+
falls out of the playbook's *cardinal rule* (re-decompose, do not
83+
transliterate) plus the rejection of monolithic instances:
84+
85+
[start=3]
86+
. **Decompose the type by aspect first, then choose mutation discipline
87+
per aspect.** `HP`, `IFrames`, `Knockback` are three independent
88+
state-machines that happen to be triggered by the same event.
89+
90+
The right answer depends on whether the three aspects are genuinely
91+
independent or whether their coupling *is* the program. Two pieces of
92+
evidence point at "genuinely independent":
93+
94+
* `update(dt)` decays the i-frame timer and the knockback timer
95+
separately. They share no state; they just both happen to decay.
96+
* The renderer reads `current`/`max` for the HP bar, the physics code
97+
reads `knockbackVelX`/`Y`, the damage code reads `invincibleTimer`.
98+
No single consumer reads all three.
99+
100+
The bundling in the original is not a design — it is a side-effect of
101+
ReScript's "one record per gameplay subsystem" idiom.
102+
103+
== The translation (aspect-decomposed, `mut`-based)
104+
105+
[source,affinescript]
106+
----
107+
// HP measured in tenths (max baseline = 1000 = 100.0 HP).
108+
// Timers measured in milliseconds (16ms ≈ 60fps frame; 1000ms = 1.0s).
109+
// Float arithmetic is unsupported in AffineScript v0.1.0 (see hitbox lesson).
110+
111+
struct HP { current: Int, max: Int }
112+
struct IFrames { remaining_ms: Int }
113+
struct Knockback { vel_x: Int, vel_y: Int, remaining_ms: Int }
114+
115+
struct Player {
116+
hp: HP,
117+
iframes: IFrames,
118+
knockback: Knockback,
119+
}
120+
121+
// Constants — module-style namespacing
122+
struct Damage {
123+
static let robo_dog_contact: Int = 150
124+
static let guard_dog_bite: Int = 100
125+
static let assassin_strike: Int = 350
126+
static let guard_melee: Int = 100
127+
}
128+
129+
struct Tuning {
130+
static let knockback_speed: Int = 200
131+
static let knockback_duration_ms: Int = 300
132+
static let iframe_duration_ms: Int = 1000
133+
static let knockback_pop_y: Int = -80
134+
}
135+
136+
// CON is a 0-200 scaling factor; baseline 100 → 1000 (= 100.0 HP in tenths).
137+
fn make(con: Int) -> Player {
138+
let max_hp = 800 + 2 * con
139+
Player {
140+
hp: HP { current: max_hp, max: max_hp },
141+
iframes: IFrames { remaining_ms: 0 },
142+
knockback: Knockback { vel_x: 0, vel_y: 0, remaining_ms: 0 },
143+
}
144+
}
145+
146+
fn is_invincible(p: ref Player) -> Bool { p.iframes.remaining_ms > 0 }
147+
fn is_alive(p: ref Player) -> Bool { p.hp.current > 0 }
148+
149+
// Position is a struct, not two same-typed labelled args:
150+
struct Pos { x: Int }
151+
152+
fn take_damage(p: mut Player, amount: Int, source: Pos, player: Pos) -> () {
153+
if is_invincible(p) { return }
154+
155+
// 1. HP — clamped subtract
156+
p.hp.current = max(0, p.hp.current - amount)
157+
158+
// 2. IFrames — start the invincibility window
159+
p.iframes.remaining_ms = Tuning.iframe_duration_ms
160+
161+
// 3. Knockback — direction is +1 right of source, -1 left of source
162+
let direction = if player.x >= source.x { 1 } else { -1 }
163+
p.knockback.vel_x = direction * Tuning.knockback_speed
164+
p.knockback.vel_y = Tuning.knockback_pop_y
165+
p.knockback.remaining_ms = Tuning.knockback_duration_ms
166+
}
167+
168+
struct Velocity { x: Int, y: Int }
169+
170+
// Tick all three timers; return knockback velocity for this frame.
171+
fn update(p: mut Player, dt_ms: Int) -> Velocity {
172+
// IFrames decay
173+
if p.iframes.remaining_ms > 0 {
174+
p.iframes.remaining_ms = max(0, p.iframes.remaining_ms - dt_ms)
175+
}
176+
177+
// Knockback decay + velocity emission
178+
if p.knockback.remaining_ms > 0 {
179+
p.knockback.remaining_ms = max(0, p.knockback.remaining_ms - dt_ms)
180+
let v = Velocity { x: p.knockback.vel_x, y: p.knockback.vel_y }
181+
if p.knockback.remaining_ms <= 0 {
182+
p.knockback.vel_x = 0
183+
p.knockback.vel_y = 0
184+
}
185+
v
186+
} else {
187+
Velocity { x: 0, y: 0 }
188+
}
189+
}
190+
----
191+
192+
== What changed
193+
194+
=== 1. One monolithic struct → three aspect structs
195+
196+
`HP`, `IFrames`, `Knockback` are nominally distinct. The compiler refuses
197+
`is_invincible(some_hp)` — only a `Player` (or, with refactoring, a
198+
`ref IFrames`) is acceptable. Each aspect has a clear lifetime, a clear
199+
mutator, and a clear reader. A future contributor adding e.g. a *poison*
200+
status effect drops in alongside the existing three rather than mutating
201+
a six-field god-struct.
202+
203+
=== 2. `mut Player`, not `State[Player]` effect
204+
205+
This is the playbook's
206+
xref:../../migration-playbook.adoc#decision-criteria[`mut` vs `State`]
207+
decision applied carefully. Two consumers (combat and physics) need to
208+
mutate `Player`; both are called from the same game-loop tick under a
209+
single owner (the `Engine` or `World`). No third caller observes the
210+
mutation across the tick boundary. **Coupling is local to one frame.**
211+
212+
If a future system needed to react to "player got hit" *between* combat
213+
and physics in the same tick, that would be the moment to lift to a
214+
`State[Player]` effect (or, more honestly, to a `Damage` effect with
215+
handlers in both subsystems). The point of `mut` here is that we get
216+
explicit local mutation now without fabricating a global effect that
217+
isn't doing any work.
218+
219+
=== 3. Tuning constants in `static let` slots, not bare module values
220+
221+
ReScript's `module Damage = { let roboDogContact = 15.0 }` works because
222+
modules are namespaces. AffineScript's idiomatic equivalent (per the
223+
hitbox lesson and the v0.1.0 surface) is `static let` inside a `struct`,
224+
which gives the same `Damage.robo_dog_contact` access path with a
225+
typed-WASM-friendly representation.
226+
227+
=== 4. Same-typed labelled args → struct types
228+
229+
`takeDamage(hp, ~amount, ~fromX, ~playerX)` becomes
230+
`take_damage(p, amount, source: Pos, player: Pos)`. The labels `fromX`
231+
and `playerX` were doing real work — disambiguating two `float` values —
232+
which is exactly the case the
233+
xref:idaptik-hitbox.adoc[hitbox lesson] flagged as the canonical
234+
"labels-want-to-be-types" pattern.
235+
236+
=== 5. Float → Int with documented precision compromise
237+
238+
* HP: tenths (max baseline 1000 = 100.0 HP). Damage values rescale by 10
239+
(`15.0` → `150`). One-decimal precision is fine for game logic.
240+
* Timers: milliseconds. 60fps `dt` is `~16ms`. The original `0.3`-second
241+
knockback duration becomes `300`. The original `1.0`-second i-frame
242+
duration becomes `1000`.
243+
244+
This is the same compromise xref:idaptik-hitbox.adoc[idaptik-hitbox]
245+
documented for collision rectangles. **Do not silently change the unit
246+
of a public field** — the source-comment header should declare the
247+
scaling factors. When AffineScript gains Float-arithmetic operators, the
248+
file can be reverted to floats with a one-pass rewrite.
249+
250+
== Playbook verdict
251+
252+
=== Held up cleanly
253+
254+
* xref:../../migration-playbook.adoc#anti-patterns[The "monolithic
255+
instance" anti-pattern] — six mutable fields under one type *is* the
256+
pattern. Aspect-decomposition follows directly from "smaller, more
257+
declarations."
258+
* xref:../../migration-playbook.adoc#decision-criteria[`mut` vs `State`
259+
decision criterion] — gave a defensible answer once we asked "do
260+
consumers observe the mutation across calls or within a tick?"
261+
* The Float → Int compromise *and* the requirement to document the
262+
scaling factors — flowed directly from xref:idaptik-hitbox.adoc[the
263+
hitbox lesson's existing rule].
264+
265+
=== Gap surfaced
266+
267+
**Aspect-decomposition vs single-record translation is not named in the
268+
playbook.** The cardinal rule says "more, smaller declarations" and the
269+
file-buffer anti-pattern shows two-types-from-one — but a *six-field
270+
mutable record with three orthogonal concerns* is a distinct pattern
271+
that wants its own row in the source-pattern index.
272+
273+
*Proposed for a v1.2 follow-up:* a row in the ReScript pattern index for
274+
"record with N orthogonal aspects bundled into one type" with the
275+
decomposition "fission into N nominally distinct types whose lifetimes
276+
and consumers differ; compose them in a parent type only when they
277+
genuinely share a lifetime." This is *the* central pattern of game-state
278+
migration and deserves explicit naming.
279+
280+
=== Decision criterion not exercised
281+
282+
The xref:../../migration-playbook.adoc#decision-criteria[coupled-writes
283+
subsection added in v1.1] did not apply here — PlayerHP has no
284+
persistence and no IO. That is information about the v1.1 addition's
285+
scope, not a gap.
286+
287+
== What this lesson covers, generalised
288+
289+
When translating a `.res` file with **multi-field mutable state**:
290+
291+
. **Look at the fields by consumer, not by name.** If different
292+
consumers read disjoint subsets of the fields, the bundling is
293+
incidental, not structural. Fission.
294+
. **Choose `mut` vs `State` by call-site distance.** If all mutators
295+
run inside one tick under one owner, `mut` is honest. If the
296+
mutation must be observed across an asynchronous boundary, lift to
297+
`State`.
298+
. **Do not lift to `State` to *organise* the code.** Effects are for
299+
observability across callers, not for namespace cleanliness.
300+
. **Document Float→Int scaling factors at the file header.** The next
301+
translator inherits the precision compromise; do not make them
302+
rediscover it.
303+
304+
== Reproducibility
305+
306+
[NOTE]
307+
====
308+
This lesson is design-translated; the toolchain run is pending. The
309+
steps below match xref:idaptik-hitbox.adoc#reproducibility[the hitbox
310+
lesson's Reproducibility section] and should produce the same shape of
311+
output.
312+
====
313+
314+
[source,bash]
315+
----
316+
$ just affinescript-stage
317+
$ ./scripts/affinescript-host-shim.sh check src/app/combat/PlayerHP.affine
318+
# Expected: "Type checking passed"
319+
$ ./scripts/affinescript-host-shim.sh compile src/app/combat/PlayerHP.affine -o src/app/combat/PlayerHP.wasm
320+
# Expected: "Compiled src/app/combat/PlayerHP.affine -> src/app/combat/PlayerHP.wasm (WASM)"
321+
----
322+
323+
When the toolchain run completes, replace this NOTE with the actual
324+
output (matching hitbox's format) and link the resulting `.wasm` size in
325+
the file header. If the run fails on a v0.1.0 limitation not anticipated
326+
here, **edit the lesson** — that is exactly the case study a future
327+
translator most needs.

docs/guides/migration-playbook.adoc

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,8 @@ Case studies from in-flight migrations land in link:lessons/migrations/[`docs/gu
273273
Existing entries:
274274

275275
* link:lessons/migrations/idaptik-hitbox.adoc[idaptik `Hitbox.res` → `Hitbox.affine`] — the first compilable translation; pure-leaf collision primitives. Surfaces the v0.1.0 Float-arithmetic typechecker gap.
276-
* link:lessons/migrations/idaptik-user-settings.adoc[idaptik `UserSettings.res` (design-only)] — multi-pattern, dependency-heavy persistence layer. Stress-tests the playbook against five external module dependencies; surfaces three small playbook gaps.
276+
* link:lessons/migrations/idaptik-user-settings.adoc[idaptik `UserSettings.res` (design-only)] — multi-pattern, dependency-heavy persistence layer. Stress-tests the playbook against five external module dependencies; surfaces three small playbook gaps (addressed in v1.1).
277+
* link:lessons/migrations/idaptik-player-hp.adoc[idaptik `PlayerHP.res` (design-translated, toolchain run pending)] — six-field mutable record with three orthogonal aspects (HP, i-frames, knockback). Stress-tests the `mut` vs `State` decision criterion; surfaces the **aspect-decomposition** pattern as a v1.2 candidate addition.
277278

278279
If you complete a non-trivial `.res → .affine` translation and the re-decomposition reveals something future translators would benefit from, write it up there. The format is loose: original snippet, faithful port, re-decomposed port, and what the second one buys you. Mark the lesson "design-only" if the destination toolchain cannot yet compile it — the design walk-through still pressures the playbook.
279280

0 commit comments

Comments
 (0)