Skip to content

Interpolate tile-authoritative movement for actors and projectiles #13

Description

@zoeyrose

Important

This preserved product issue is now part of the fresh MIT replacement program. Final implementation owner: atrinik/client. Legacy C/SDL2, packet, global-state, and file-path details below are historical evidence only.

Replacement implementation contract

Preserve tile authority, interpolation, snap, latency, projectile, and disclosure rules. Use stable entity generations plus authoritative simulation ticks in Game Protocol 1; interpolation is presentation state and never changes committed session position.

New implementation and tests are independent MIT work unless an exact contribution by an approved MIT provenance grantor is admitted through the recorded file-level MIT grant. Preserve every player-facing, accessibility, disclosure, and performance design decision below.

Required verification

  • Test pure state/geometry/material/UI behavior headlessly where possible and run supported Linux/Windows integration paths.
  • Add bounded malformed, stale, lifecycle, resource-loss, and recovery cases appropriate to the owner.
  • Add shared Go/Rust protocol fixtures for authoritative fields; presentation never reconstructs hidden rules.
  • Use released shared renderer/protocol/toolkit contracts and wrapper-managed replacement scenarios.
Preserved product/design specification and historical implementation notes

Summary

Keep Atrinik's server-authoritative tile simulation and existing integer
x/y/z object coordinates, but render players, monsters, and moving
projectiles at interpolated sub-tile positions between authoritative MAP
snapshots. Drive their walk/projectile animation from elapsed time and motion
progress so an 8 Hz server can look fluid at the client's 30/60/120 Hz present
rate without changing collision, pathfinding, combat, LOS, or map semantics.

This should be a classic client/server vertical slice, not client-authoritative
movement. Initial local movement begins when the server confirms the step;
client prediction and rollback are deliberately separate work.

Why the current client snaps

The codebase already has the two cadences needed for interpolation, but not the
entity model:

  • The server's default tick is 125 ms (MAX_TIME), or 8 simulation ticks per
    second. socket_server_post_process() calls draw_client_map() and
    draw_client_map2() sends a MAP packet even when its level blocks contain no
    changed tiles.
  • The client main loop can present at 30, 60, 120, or unlimited FPS, but
    map_animate() advances map animation counters only every 125 ms.
  • CLIENT_CMD_MAP is a delta of tile/layer appearance. The client MapCell
    stores faces, animation flags/state, names, target data, and lighting at an
    integer cell. display_mapscroll() immediately rotates the caches when the
    local player changes tile, and map_draw_map() projects every object from
    that cell's integer anchor.
  • There is no stable render identity for all moving objects. A living object's
    head->count is sent only through MAP2_FLAG2_TARGET, the local player's ID
    is not sent, and effect-layer projectiles do not carry an ID. Matching a
    clear on tile A to an appearance on tile B by face/layer would fail when two
    identical monsters cross, entities swap tiles, a projectile changes facing,
    or an object is replaced by another with the same art.
  • Projectiles remain ordinary authoritative objects. For example,
    common_object_projectile_process() and
    common_object_projectile_move() move arrows/spells by one integer tile via
    object_move_to(), so they can use the same presentation pipeline as actors.
  • The map widget caches a complete software surface and redraws it only when
    map_redraw_flag is set. Correct moving sprites also participate in the
    unified cross-depth painter queue, lighting, walls/roof occlusion, names,
    target HP, and effects; drawing them as a late overlay would be visibly
    wrong.

The missing abstraction is therefore a short-lived client render entity with
stable identity and a time-ordered authoritative tile history. Fractional
coordinates belong only to that render state.

Target behavior

  • The server continues to resolve movement atomically on integer map tiles.
  • At render time, an actor/projectile has an interpolated foot/ground anchor
    between its previous and current authoritative tiles. Eight-directional
    movement follows the existing isometric projection.
  • The local player remains at the camera anchor during ordinary walking while
    the world/camera offset eases by one confirmed tile. Remote actors and
    projectiles move through the same interval relative to that camera.
  • Continuous movement is visually continuous. Stopping settles exactly on the
    authoritative destination. A blocked move does not start a visual segment.
  • Teleports, new maps, authorization/visibility changes, incompatible physical
    depth changes, large discontinuities, and stale histories snap instead of
    animating through unknown or hidden space.
  • Walk/projectile animation is time-based and synchronized to segment progress.
    Existing discrete sprite frames remain pixel art; this issue does not invent
    frame morphing or new artwork.
  • Names, status icons, target bars/outlines, damage/kill annotations, glow,
    lighting, hit regions, and debug bounds follow the interpolated render
    transform.
  • The minimap, region map, pathfinding, target authority, FOW/LOS, and all
    gameplay coordinates remain tile-based.

Protocol contract

Make one intentional classic protocol break and update all in-repository
producers/consumers rather than inferring identity client-side.

MAP snapshot timing

Add these fixed fields to every CLIENT_CMD_MAP header after the existing
player position/sub-layer fields:

simulation_tick   uint32
tick_duration_us  uint32

simulation_tick is the authoritative global_round_tag for the state being
serialized. tick_duration_us reflects the current server tick duration, so
the existing configurable --speed (and runtime speed changes) do not leave
the client assuming 125 ms. Repeated empty/delta MAP packets at the same tick
must not advance the snapshot timeline. Tick comparisons must be wrap-safe.

Define and enforce sensible duration bounds and reject truncated or invalid
timing fields before applying the packet.

Stable render entity identity

Add an extended MAP2 layer field, for example MAP2_FLAG2_ENTITY, containing:

entity_id      uint32
motion_flags   uint8

Use the server object's existing connection-visible head->count/tag_t as
entity_id. Send it for every disclosed player, monster, and moving
projectile, including the local player. motion_flags should be a bounded bit
contract that at minimum identifies an interpolatable entity and the
client-controlled camera anchor; reserve explicit semantics for projectile or
snap-only presentation only if the renderer needs them. Unknown bits are an
error until defined.

Multi-part objects use the head ID and the existing part/quick_pos identity,
so every visible part receives one group transform. Add entity ID/flags to the
server's per-socket MapCell cache: replacing an object with another object
that has the same face and animation data must still emit a delta.

Layer clears and appearances for one simulation tick must be staged before
updating render histories. A clear at A plus the same entity ID at adjacent B
is one movement segment, not a despawn/spawn. An unmatched clear retires the
entity; an unmatched appearance creates it at the authoritative position
without flying in from guessed coordinates.

Specify direction, framing, byte order, field order, identity lifetime,
multi-part semantics, valid flags, bounds, clearing, map/depth transitions,
tick wrap, and malformed/trailing-data behavior in doc/ADS/ADS-2. Bump
SOCKET_VERSION and update common/toolkit/socket.h, the server serializer and
cache, client parser/cache, tests, and any bot/protocol consumer together. Do
not retain a parallel identity-free MAP path.

Client architecture

1. Add a render-entity registry beside the tile cache

Keep MapCell as the latest authoritative disclosed tile state. Add a bounded
registry keyed by entity ID (and part where applicable) containing the current
visual properties plus at least two timestamped authoritative transforms:

map/depth, tile_x, tile_y, sub_layer, z/support height, simulation_tick

The MAP parser should stage a complete framed update, validate it, then commit
tile changes and entity-history changes atomically. Shift histories with
display_mapscroll() and map_level_scroll() just as map animations are
shifted today; clear them on new maps, teleports, disconnects, hard visibility
loss, and incompatible level transitions. Bound entries to disclosed entities
and retire absent histories promptly.

2. Use a small interpolation buffer, never unbounded extrapolation

Map monotonic client time to the server snapshot timeline and normally render
about one simulation tick behind the newest authoritative state. Interpolate
the projected foot anchor and elevation between two known states using a
well-defined linear or smoothstep curve. A small adaptive allowance for
measured arrival jitter is reasonable, but cap it tightly.

If the next snapshot is late, finish at the known endpoint and hold. Do not
keep extrapolating actors/projectiles through walls, undisclosed cells, or
future collisions. Snap on non-adjacent displacement, invalid history,
authorization loss, or a buffer overrun.

For the local player, a confirmed same-map step creates the equivalent inverse
camera/world offset after display_mapscroll() rotates the authoritative
cache, then interpolates that offset to zero. This makes the camera glide
without changing the player's tile or predicting whether a requested move will
succeed.

3. Integrate fractional anchors into the existing painter

Extend map_render_command_t (or its successor) with a fractional/fixed-point
world anchor and derive screen position, projected bounds, and painter keys
from the interpolated foot point rather than always from the destination
cell. Interpolate elevation on stairs/sloped floors from already authorized
source/destination support samples. Preserve the documented unified
back-to-front order across physical depths; an actor must pass behind/in front
of walls and roofs at the correct point in the segment.

Lighting may interpolate the two disclosed tile samples cosmetically, but it
must never infer visibility or a light source the server did not send. Target
and pointer hit testing should prefer the interpolated entity bounds and send
the stable ID plus its latest authoritative tile; the server continues to
validate identity, visibility, position, and range.

The current software renderer may initially rebuild the painter queue while
motion is active, provided profiling shows the supported client can maintain
its selected cadence. Set redraw/present continuously only while an entity,
camera segment, or time-based animation is active; idle scenes must retain the
current no-redraw behavior. atrinik/renderer#17 and atrinik/renderer#16 can later move the same transforms to
GPU/retained commands without changing simulation or protocol semantics.

4. Make moving animation elapsed-time based

Move actor/projectile playback away from the global 125 ms
anim_last/anim_state scan. Keep the existing animation ID, facing, and bank
selection, but select frames from monotonic elapsed time:

  • movement-bank phase derives from distance/progress across consecutive
    segments, so walk cycles do not restart on every tile;
  • a stop settles into idle cleanly, while a direction change selects the new
    facing without changing authoritative position;
  • existing anim_speed can initially mean anim_speed * tick_duration for
    idle/non-motion clips, removing the hard-coded client assumption;
  • projectile facing/spin/clip timing remains independent of render FPS;
  • the explicit action timeline proposed by Replace post-action cooldowns with server-authoritative wind-ups and timed animations server#25 has priority over the movement
    clip while still inheriting the entity's interpolated transform.

Animation speed determines which discrete authored frame is shown, while the
movement transform updates every presented frame. More sprite frames or a new
clip metadata format can be separate content work.

Implementation sequence

  1. Add deterministic tests/diagnostics around current MAP deltas, player cache
    scrolling, moving identical actors, and projectiles; record packet,
    map-render, and frame-time baselines at the default 8 Hz server rate.
  2. Define snapshot timing and entity identity in ADS-2, bump the protocol, and
    update the server cache/serializer and client parser transactionally.
  3. Introduce the bounded render-entity history and cover spawn, adjacent move,
    swap/crossing, removal, same-art replacement, multi-part, depth, map-scroll,
    and discontinuity cases without fractional rendering.
  4. Add remote actor/projectile interpolation, fractional painter keys,
    elevation/lighting handling, annotations, and visual hit testing.
  5. Add confirmed local-player camera interpolation and verify cardinal,
    diagonal, blocked, continuous, path, connected-map, and teleport behavior.
  6. Convert moving animation playback to elapsed time/segment progress and
    integrate the action-state precedence from Replace post-action cooldowns with server-authoritative wind-ups and timed animations server#25 if that work has landed.
  7. Profile software redraw and optimize only from measurements; integrate with
    Build the shared SDL3 and wgpu GPU renderer renderer#17's GPU renderer and Retain and incrementally invalidate GPU scene data renderer#16's retained representation when available.
  8. Run protocol malformed-input tests, both native builds, server checks, live
    high-jitter testing, and video/screenshot comparison; document the final
    timing, registry, and authority boundaries.

Acceptance criteria

  • With the server at its default 8 Hz and the client at 60/120 Hz,
    continuous players, monsters, and projectiles show monotonic intermediate
    render positions between adjacent authoritative tiles instead of
    teleporting once per MAP update.
  • Server object coordinates, collision, LOS/FOW, pathfinding, targeting,
    combat, projectile hits/reflection/stopping, and map disclosure remain
    integer-tile authoritative and behaviorally unchanged.
  • Local cardinal/diagonal walking and click paths glide the camera after
    confirmation; rejected/blocked movement produces no false step, and
    teleports/new maps snap cleanly.
  • Two identical entities can cross or swap tiles without changing identity,
    merging, duplicating, flickering, or borrowing each other's animation.
  • Arrows and spell projectiles interpolate until the authoritative impact,
    reflection, stop, removal, or conversion to a dropped item; no client
    visual continues through a collision.
  • Names, target bars/outlines, status icons, damage text, glow, occlusion,
    painter order, elevation, and lighting remain attached to the moving
    entity and correct across stacked depths.
  • Clicking/targeting a visually interpolated actor resolves its stable ID
    while the server still validates its latest authoritative tile and
    visibility.
  • Long stalls, tick-duration changes, tick wrap, malformed deltas,
    non-adjacent moves, visibility loss, connected maps, and depth changes
    have deterministic bounded hold/snap behavior with no extrapolation into
    undisclosed state.
  • Walk/projectile motion is render-FPS independent; changing among
    30/60/120 FPS changes smoothness, not travel time or animation speed.
  • Idle scenes do not redraw continuously. Motion scenes meet the selected
    cadence on a representative ordinary map and a +1/+2 stacked scene, or
    measured renderer work is reduced through the retained/GPU path before
    merge.
  • Packet size/rate, render-entity count, interpolation delay/underruns,
    snap reasons, map traversal/sort time, drawn FPS, and frame time are
    visible in diagnostics or the render profiler.
  • Round-trip and malformed-packet tests cover the new fields and atomic
    commit behavior; both legacy C targets build warning-free, prepared
    server checks pass, and live video demonstrates actor and projectile
    motion under both low jitter and injected 50/150 ms jitter.

Out of scope

Related work

Protocol-epoch coordination

Coordinate this wire change through atrinik/atrinik#168's next classic protocol epoch with atrinik/server#26, atrinik/server#25, atrinik/atrinik#156, #13, #9, #7, and atrinik/server#6 where practical. Do not reserve an isolated numeric version in advance. Land atrinik/atrinik#190's bounded packet primitives first where this payload uses them, then update all current producers, consumers, bots, fixtures, tests, and ADS-2 together.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Fields

    Priority

    None yet

    Start date

    None yet

    Target date

    None yet

    Effort

    None yet

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions