Investigate float/long for position & vector types (currently int px + Q16.16 fixed) #78

Closed
opened 2026-09-02 04:52:39 +02:00 by orkun · 2 comments
Owner

Positions are integer pixels and velocities are fixed (Q16.16). For a general 2D engine this may be limiting: sub-pixel movement, large/scrolling worlds, camera zoom, precision.

Investigate / RFC: an f32/f64 and/or i64 option for Position and the Vector type; the tradeoffs; and whether Position should be a typed Vector rather than raw int fields.

Determinism note: fixed-point was likely chosen for deterministic replay/lockstep netcode — any float option must preserve that deterministic path (or be opt-in).

Positions are integer pixels and velocities are `fixed` (Q16.16). For a general 2D engine this may be limiting: sub-pixel movement, large/scrolling worlds, camera zoom, precision. Investigate / RFC: an f32/f64 and/or i64 option for Position and the Vector type; the tradeoffs; and whether Position should be a typed Vector rather than raw int fields. Determinism note: fixed-point was likely chosen for deterministic replay/lockstep netcode — any float option must preserve that deterministic path (or be opt-in).
Author
Owner

Investigated and decided — see docs/RFC-POSITION-TYPES.md (commit 7f55554).

Decision: keep the deterministic integer + Q16.16 core; no hardware floats.

  • f32/f64 positions — rejected. IEEE float results vary with rounding mode, FMA contraction, and libm across platforms, which would desync lockstep netcode, break Input.replay, and diverge world_save snapshots and headless golden renders. Determinism is the product; that is why the language has no f32/f64 in the first place.
  • Sub-pixel movement — already solved. Body carries Q16.16 rx/ry accumulators; vx/vy integrate into them and only the whole-pixel carry reaches the integer Position. Documented now as the canonical pattern.
  • Large worlds — range, not representation. i32 pixels already reach ±2.1e9. A wider Position64 (i64) is a clean additive opt-in to add only when a real game hits the wall; BigInt/Huge already cover exact big numbers.
  • Camera zoom — a render-time Q16.16 scale on the camera, not a coordinate change. Reuses the existing deterministic fixed-point blit path.
  • Position as a typed Vector — no. Position is a reflected/saved/networked component (fields addressed by name across move/bounds/sprite/light systems, save, and net deltas); IVec2/Vector are the by-value math types. Games convert at the boundary; conflating them would churn the whole reflection/save/net surface for a cosmetic win.

Closing as resolved with the recorded rationale. Pairs with #77 (Position now shipped canonically from ludic.core).

Investigated and decided — see docs/RFC-POSITION-TYPES.md (commit 7f55554). **Decision: keep the deterministic integer + Q16.16 core; no hardware floats.** - **f32/f64 positions — rejected.** IEEE float results vary with rounding mode, FMA contraction, and libm across platforms, which would desync lockstep netcode, break Input.replay, and diverge world_save snapshots and headless golden renders. Determinism is the product; that is why the language has no f32/f64 in the first place. - **Sub-pixel movement — already solved.** Body carries Q16.16 rx/ry accumulators; vx/vy integrate into them and only the whole-pixel carry reaches the integer Position. Documented now as the canonical pattern. - **Large worlds — range, not representation.** i32 pixels already reach ±2.1e9. A wider Position64 (i64) is a clean additive opt-in to add only when a real game hits the wall; BigInt/Huge already cover exact big numbers. - **Camera zoom — a render-time Q16.16 scale on the camera, not a coordinate change.** Reuses the existing deterministic fixed-point blit path. - **Position as a typed Vector — no.** Position is a reflected/saved/networked *component* (fields addressed by name across move/bounds/sprite/light systems, save, and net deltas); IVec2/Vector are the by-value *math* types. Games convert at the boundary; conflating them would churn the whole reflection/save/net surface for a cosmetic win. Closing as resolved with the recorded rationale. Pairs with #77 (Position now shipped canonically from ludic.core).
Author
Owner

Reopened and shipped a real, tested implementation (6a83c28), not just the RFC.

Camera.zoom(scale) — a deterministic Q16.16 render-time zoom. It scales every draw about the screen centre through the same two framebuffer chokepoints (rt_put_px / rt_fill_rect) that already carry the camera offset, so it composes with Camera.set/follow/shake. 1.0 = none, 2.0 = 2x in, 0.5 = out; 1.0 turns the path back off.

Backward-compatible by construction: gated behind an internal rt_cam_zoomed flag so any game that never zooms takes the original int-subtraction blit path and renders byte-for-byte identically — all golden renders unchanged, C-free bootstrap fixpoint intact, full suite 105/0.

Verified with examples/library/camera_zoom.ludic (pixel-readback: draws a box, zooms 2x, confirms it moved+scaled to the predicted screen cell, then zoom(1.0) restores it) wired as a feat_case.

This is the concrete outcome of the investigation: the world coordinate types stay integer px + Q16.16 velocity (hardware floats rejected — they'd desync lockstep/replay/world_save), and zoom is delivered where it belongs, as a render-time transform on the camera. Sub-pixel motion was already solved (Body.rx/ry accumulators); i64 world extent (Position64) remains a clean additive opt-in to add only when a real game needs >±2e9 px. Rationale recorded in docs/RFC-POSITION-TYPES.md.

Reopened and shipped a real, tested implementation (6a83c28), not just the RFC. **`Camera.zoom(scale)`** — a deterministic Q16.16 render-time zoom. It scales every draw about the screen centre through the same two framebuffer chokepoints (rt_put_px / rt_fill_rect) that already carry the camera offset, so it composes with Camera.set/follow/shake. `1.0` = none, `2.0` = 2x in, `0.5` = out; `1.0` turns the path back off. Backward-compatible by construction: gated behind an internal `rt_cam_zoomed` flag so any game that never zooms takes the original int-subtraction blit path and renders **byte-for-byte identically** — all golden renders unchanged, C-free bootstrap fixpoint intact, full suite **105/0**. Verified with `examples/library/camera_zoom.ludic` (pixel-readback: draws a box, zooms 2x, confirms it moved+scaled to the predicted screen cell, then zoom(1.0) restores it) wired as a feat_case. This is the concrete outcome of the investigation: the world coordinate types stay integer px + Q16.16 velocity (hardware floats rejected — they'd desync lockstep/replay/world_save), and zoom is delivered where it belongs, as a render-time transform on the camera. Sub-pixel motion was already solved (Body.rx/ry accumulators); i64 world extent (`Position64`) remains a clean additive opt-in to add only when a real game needs >±2e9 px. Rationale recorded in docs/RFC-POSITION-TYPES.md.
orkun closed this issue 2026-09-02 05:55:51 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: workshopsoft/ludic#78
No description provided.