HEXWORK - A Lantern in the Deep: 3D ASCII terminal roguelike RPG in Rust. Entirely designed and built by Muse Spark 1.3 to evaluate if I like the model.
hexwork docs fluid-player-design.md
21 kB
Markdown
at main

Fluid player correction contract — approved target, not implemented #

Status: This is an approved design target for the Fluid HEXWORK player correction. It is not an implementation report, a release note, or an acceptance result. No Fluid runtime, dependency, save migration, or schema change is delivered by this document.

Annotated baseline: local legacy-grid-final at the exact commit 3f81e656bead175606b31a87cb15c9068f0d1c2b.

Current executable: until implementation advances, cargo build still produces the legacy executable. It does not implement the Fluid contracts below.

This document freezes the correction boundary so implementation can proceed in small, reviewable slices. “Approved target” means the contract is the intended behavior. “Implemented” means the current source and executable demonstrably provide that behavior; this document does not turn one status into the other.

1. Target versus actual status #

Area Approved Fluid target Current local status
Actor authority State::actors is the single authority for all actor transforms, in signed i64 millimetres Legacy grid authority remains in the executable
Simulation 10 Hz, exactly 3 physics substeps per authoritative tick Legacy timing/runtime path remains active
Player geometry 3000 mm/s; half-width/depth 300 mm, height 1800 mm; FULL AABB 600 × 600 × 1800 mm; 1 mm skin; 250 mm step Fluid geometry is not delivered
Heading Integer heading with 4096 units per full turn Fluid heading contract is not delivered
Pacing Action-paced default; realtime Tactical and realtime Continuous are independent policies, orthogonal to death Existing executable is not a Fluid implementation
Effects and Inn Explicit durations; pending descriptor final-tick ordering; resumable Inn with exact debt rules Not delivered
Observations Lit integer LOS terrain faces only, stale remembered terrain, unknown/blank presentation, and a north-up compact map Not delivered
Persistence Intentional future v5 schema break; no silent migration Current formats and readers remain legacy
Local presentation Optional local SDL2 0.38/fontdue 0.9.3 path is intended, not required for SSH Dependencies and local client are not delivered

A build, unit test, screenshot, or successful legacy play session must not be reported as Fluid acceptance. The target remains open until the implementation and its human/runtime checks actually land.

2. Coordinate and actor authority contract #

2.1 One authority: State::actors #

State::actors is the one authoritative collection for all actors: the player, NPCs, monsters, and every other actor kind. It owns each actor's canonical transform and liveness. The player is not a special second authority. Collision, movement, fall resolution, action effects, save/replay state, and observations read State::actors or a checked snapshot of it. No player-only position, NPC shadow transform, floating-point transform, or independently mutable grid location may silently become authoritative.

The implementation may derive voxel candidates, render coordinates, projection DTOs, and broad-phase bounds, but those are projections. A projection must never write back a rounded or truncated position without an explicit checked operation. There is no wraparound, saturation, or “nearest valid cell” recovery for an invalid authoritative position.

2.2 Signed millimetres and Euclidean voxel floor #

Authoritative actor feet positions are signed i64 millimetres. One voxel is 1000 mm. Converting millimetres to a voxel index uses Euclidean floor, never language integer truncation toward zero:

voxel(mm) = floor(mm / 1000)

Thus -1 mm maps to voxel -1, -1000 mm maps to -1, -1001 mm maps to -2, 0 mm maps to 0, and 999 mm maps to 0. The implementation must use an explicitly Euclidean operation such as div_euclid and test negative boundaries.

The voxel index and its permitted margin are signed i32 with a one-cell guard margin at each end: the usable canonical index domain is

[i32::MIN + 1, i32::MAX - 1]

The reserved edge cells are not valid occupancy/query targets. This margin is part of the v5 domain, not an implementation convenience. All actors and all voxel projections obey it, including negative coordinates and chunk edges.

2.3 Checked projection ordering #

A movement, collision, fall, or observation query computes its complete body, skin, step, and one-cell-margin envelope in checked i128 intermediates before narrowing to voxel indices:

  1. Start with the signed i64 millimetre authority in State::actors.
  2. Add/subtract the full AABB, 1 mm skin, movement/fall step envelope, and required one-cell voxel margin in i128.
  3. Reject arithmetic overflow/underflow and every envelope that could project outside the usable i32 domain before indexing, allocation, or a world read.
  4. Apply Euclidean floor and perform deterministic voxel queries/collision checks. Never wrap, truncate toward zero, or clamp an invalid coordinate.

A valid voxel projection is a checked read/validation result, never a second actor authority. The same rule applies at the minimum and maximum world edges, across negative chunks, during three-substep movement, and for LOS targets.

3. Fixed simulation and player geometry #

The Fluid simulation uses these exact constants:

Contract Value
Authoritative simulation rate 10 Hz (100 ms per tick)
Physics substeps Exactly 3 per authoritative tick
Player speed 3000 mm/s
Player body half-width 300 mm
Player body half-depth 300 mm
Player body height 1800 mm
Player full AABB 600 × 600 × 1800 mm (full width × full depth × height)
Collision skin 1 mm
Step height 250 mm
Heading resolution 4096 integer units per full turn

At the nominal speed, one authoritative tick permits 300 mm of travel and its three equal substeps permit 100 mm each before collision constraints. Movement preserves the speed cap and makes collision/step decisions at substep boundaries. Render cadence, terminal latency, and input-arrival timing must not alter authoritative displacement.

Heading is a canonical integer with 4096 units per full turn. It is not a floating-point render angle and is not inferred exclusively from movement. Presentation yaw/compass state is separately defined in the map section below.

4. Pacing, actions, and death #

4.1 The three pacing policies #

The following table is normative. A policy changes when simulation work runs; it does not select or rewrite a death policy.

Policy Idle, look, and browse Held/blocked movement and debt Other exact behavior
Action (default) Idle is frozen. Look and browse are free observational work. A held or blocked move is still a 100 ms move attempt in all phases. Pending-action and fall debt continue their cadence. Accepted actions run according to their declared duration.
Tactical Idle is live. An explicit pause is deferred until existing debt is settled. Browsing does not autopause. A held or blocked move is still a 100 ms move attempt in all phases; pending-action and fall debt continue their cadence. Step is one wait (1 tick), while a chosen action runs for its whole declared duration.
Continuous Idle is live. Menus request a system pause; noninteractive presentation does not pause the simulation. A held or blocked move is still a 100 ms move attempt in all phases; pending-action and fall debt continue their cadence outside a system-paused menu. Realtime menu/system pause is a pacing behavior, not a death-policy choice.

Look and browse never mutate the authoritative world merely because a view is open. In Tactical, “pause” is an explicit request with the stated debt barrier; opening a browser is not an implicit pause. In Continuous, only the explicit system pause for an interactive menu stops noninteractive work; ordinary noninteractive presentation remains live.

Death is orthogonal to all three policies. Story, Classic, and any later death policy retain their own lifecycle and recovery rules in every pacing policy. Death is an authoritative boundary: queued player work stops while the applicable death/lifecycle transition is resolved. A pacing mode must not hide, postpone, or resume a dead-branch action.

4.2 Exact action durations #

Durations are authoritative simulation ticks (100 ms each), not wall-clock animation lengths, terminal frames, key-repeat intervals, or guessed defaults. The approved table is:

Action Exact duration Timing class
Walk / move 1 tick World
Wait 1 tick World
Pickup 10 ticks World
Drop 10 ticks World
Wield 10 ticks World
Accept contract 10 ticks World
Turn in 10 ticks World
Service (every service other than Inn) 10 ticks World
Transition 10 ticks World
Attack 10 ticks World
Identify 20 ticks World
Inn/rest authored current 8640 ticks Unattended
InspectBoard observational; no committed action duration Observation

InspectBoard is an observational request, not a committed action. It records no action event, consumes no simulation duration, changes no canonical hash, and does not enter pending-action history. It is not a zero-duration committed action. There is no zero-duration default: every committed action must match an explicit positive row in the table (or a separately approved future row), and an unknown/missing/negative/overflowing duration is invalid rather than zero.

The v5 implementation must make action timing exhaustive: no wildcard timing arm, implicit fallback, or service-wide default may accidentally assign a new action a duration. Save/replay records carry enough explicit timing data to reproduce the same tick boundaries.

4.3 Pending descriptors and final-tick ordering #

A pending effect stores a small immutable descriptor: action/effect kind, source identity, exact target identity/coordinates/instance ID as applicable, start/final tick, and the approved parameters. It does not clone State, a world image, or a target's mutable object. The descriptor is an intent to revalidate, not permission to apply a stale effect.

At the descriptor's final tick, process the final current state in this order:

  1. Advance the world and all required Fluid substeps for that tick.
  2. Resolve world hazards, actor liveness, and death at the world boundary.
  3. If the actor/lifecycle is still permitted to continue, revalidate the pending descriptor against the final current State and the exact target identity/coordinates/instance ID.
  4. Apply the effect exactly once only after that revalidation, then commit the state/history/observation boundary.

A missing, moved, changed, dead, or otherwise stale exact target rejects or aborts the pending effect; it never retargets to another actor, item, cell, or service. Revalidation must not use a cloned State. If the world/death step reaches a death boundary, the dead branch's pending effect is aborted and is never applied or resumed; lifecycle handling cannot be bypassed by leaving a descriptor around.

4.4 Inn admission, unattended work, and debt #

Inn/rest is the authored-current 8640-tick unattended action and is resumable, but it is not an unbounded fast-forward:

  • Debit the Inn cost exactly once at successful action admission. A rejected admission does not debit; a later interruption, death, save failure, or reload does not refund or debit it again.
  • Each unattended-work iteration advances at most 5 unattended ticks.
  • The unattended whitelist is exactly: NPC intent clocks, canonical NPC locomotion, item clocks, corpse clocks, and gossip clocks.
  • Normal hazards are excluded from unattended Inn work. Do not run normal hazard simulation as an accidental side effect of resting.
  • Apply the rest effect exactly once, only when the full admitted action completes. Partial iterations do not rest repeatedly.
  • Persist the remaining Inn debt/pending descriptor at the normal save boundary. Save/reload preserves that debt exactly: reload performs no refund, re-debit, repayment, or debt clearing, and resumes only the remaining work.

The final Inn completion follows the same world-then-death-then-final-effect ordering. If a death boundary aborts the dead branch, its pending Inn effect does not resume on resurrection; only pending state that was already part of the accepted Story checkpoint can be restored by Story rollback.

5. Terrain observation and lighting contract #

Fluid observation is a bounded, deterministic projection of terrain only. The observation memory contains no remembered actors, items, services, hazards, actor identities, item identities, service availability, or other gameplay facts. Such information must not be smuggled into a terrain observation cache.

5.1 Positive integer light and first-hit LOS #

A current terrain face is observable only through positive local light represented as an integer and a first-hit integer LOS test. The LOS range is at most 48 m (48,000 mm). The canonical ray origin at an observation boundary is the authoritative actor feet position plus 1600 mm on the vertical axis:

origin = actor_feet + [0 mm, 0 mm, 1600 mm]

At every canonical observation boundary, including spawn and arrival, perform this observation omnidirectionally and independently of the camera. Camera orientation cannot hide a required boundary sample or make unlit geometry current. A first terrain-face hit within 48 m is the only terrain geometry eligible for that sample.

Face targets are sampled 1 mm into the air from the terrain face. This one-millimetre air offset makes the target side of a face unambiguous and is part of the integer boundary/LOS contract. All range, boundary, light, and LOS arithmetic is checked; floating-point camera/render values are not authority.

There is no live-geometry handle crossing into the projection. The renderer and map receive detached, bounded observation values; they cannot retain a pointer, reference, mutable handle, or lazy query into live geometry.

5.2 Observation states and stale memory #

State Required meaning and presentation
Lit A current terrain face passed positive integer local light and first-hit LOS. It may be rendered as current terrain.
Remembered / stale A terrain face observed earlier but not currently visible. It remains explicitly stale and is not refreshed by hidden world changes.
Unknown No valid terrain observation has ever been obtained. Do not infer terrain, occupancy, light, safety, actors, items, or services.
Blank No terrain face is returned for this presentation cell. Render blank; do not turn blank into guessed floor or an omniscient dump.

Hidden changes stay stale. If terrain, light, occupancy, or a boundary changes while a face is hidden, its remembered value remains the old stale value until a new valid observation replaces it. Stale/unknown/blank values cannot authorize movement, collision, targeting, service use, or a current-world claim. The observation cache is bounded and is cleared or restamped only at its explicit lifecycle boundary.

6. North-up map and compass presentation #

The tactical map is NORTH-UP. North remains at the top; the map does not rotate with player heading, camera yaw, or compass yaw. The approved map sizes are:

Terminal/layout Map viewport
Normal 80 × 24 terminal 15 × 9
Compact layout 9 × 5

The map shows only permitted current/stale/unknown/blank terrain observations; it is not an omniscient geometry view and never becomes movement authority.

The compass's presentation yaw is separate from canonical actor heading. It may be changed freely and immediately for presentation, independently of any captured canonical heading. Canonical heading is not derived from movement exclusively, and a presentation compass change does not mutate actor authority, world direction, map north-up orientation, or replay state.

7. Story checkpoint rollback and epochs #

A Story checkpoint is a complete accepted rollback boundary. Story death rolls back all pacing state, all knowledge, and all pending state to the checkpoint, not merely player position and HP. This includes the selected pacing policy, cadence/debt/accumulator state, learned observation/knowledge state, and any pending descriptors that were part of the accepted checkpoint. After rollback, restamp a new epoch so a descriptor, target, observation, or queued input from the dead branch cannot pass as current.

Every action/effect admitted after that checkpoint on the now-dead branch is aborted and does not resume. In particular, a pending effect that was in flight on the dead branch is not replayed after resurrection. Only pending state that was already present in the checkpoint may be restored, and it must still obey final-current-State exact-target revalidation before any effect can apply.

8. Persistence, schema, and optional local presentation #

8.1 Future v5 schema break #

The Fluid authority, timing, pending descriptor, Inn debt, pacing, observation, and epoch contracts require a deliberate future v5 schema break. v5 is not emitted by the current executable, and no v4-to-v5 migration is authorized by this document. There must be no silent reinterpretation of legacy positions, actions, ticks, pending state, observations, hashes, or map orientation.

When implementation advances, incompatible prior artifacts must receive a clear version/compatibility result before their bodies are interpreted. Exact v5 fields, action tags, timing data, canonical ordering, bounds, epoch rules, limits, and old-format behavior must be reviewed and frozen together. A placeholder version bump is not a migration plan.

8.2 Optional local SDL2/fontdue path #

An optional local presentation path using SDL2 0.38 and fontdue 0.9.3 is intended for a later local development/client slice. It is not delivered, not a dependency change in this documentation-only correction, and not a requirement for the ordinary terminal/SSH path.

Ordinary terminal SSH remains the intended constrained-client route. It must not depend on SDL2, fontdue, a GPU, or a local graphical session. Conversely, terminal/SSH evidence does not prove that the optional local path exists or that Fluid implementation is complete.

9. Safe isolated baseline checkout/build #

Use a separate detached worktree. Do not replace the current checkout, alter its branch, migrate saves, push a branch, or treat a build as Fluid delivery. The annotated local baseline is the exact commit shown below, not a moving branch name:

# Run from the repository root; choose a disposable sibling path.
git worktree add --detach ../hexwork-fluid-player-legacy-grid-final \
  3f81e656bead175606b31a87cb15c9068f0d1c2b
cd ../hexwork-fluid-player-legacy-grid-final
git rev-parse HEAD   # must print 3f81e656bead175606b31a87cb15c9068f0d1c2b
cargo build --locked --release
./target/release/hexwork --help

The resulting binary is still the legacy executable until Fluid implementation advances. Keep the worktree isolated and disposable; do not run git push, git merge, git rebase, or a save/schema migration as part of this checkout or build check. Remove only the disposable worktree when finished:

cd -
git worktree remove ../hexwork-fluid-player-legacy-grid-final

10. Acceptance boundary and open human check #

The earlier first-person acceptance failure remains preserved: the first-session observation reported that the advertised Contracts key terminated play. It is not reclassified as a pass by the Fluid target. The README normal-brightness first-person route is still pending human execution; screenshots, terminal identity/dimensions, color mode, and an explicit human pass/fail remain required. Automated tests and SVG/terminal artifacts cannot certify font or monitor readability or the complete HEXWORK experience.

No Fluid acceptance, v5 compatibility result, SDL2/fontdue delivery, ordinary SSH certification, or HEXWORK completion claim is made here. A pending effect that encounters death is aborted on the dead branch; it is never applied or resumed, including after Story resurrection or a later load.