Skip to content

Hands: realism overhaul (simulation feel, netcode, animation, injuries, presentation) - #293

Merged
user1303836 merged 148 commits into
mainfrom
feature/hands-realism
Oct 7, 2026
Merged

user1303836 merged 148 commits into
mainfrom
feature/hands-realism

Conversation

@user1303836

@user1303836 user1303836 commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Summary

Realistic, responsive Hands boxing with injuries and gore, on the existing authoritative engine, rooms, persistence, and ratings. Baseline findings and the phased plan are in docs/hands-realism-plan.md.

Landed

  1. Simulation feel and netcode

    • Fixed-point facing vector with limited turn rate; hit test measures forward and lateral distance against it. Anti-parallel facings turn via the perpendicular instead of stalling.
    • Queued punches survive the current punch; a landed punch can cancel the second half of its recovery into a combo follow-up.
    • Knockdown get-up no longer teleports; the standing fighter walks to neutral distance during the count.
    • Between rounds the engine walks both fighters to their diagonal corners and clears any attack, defense, stun, taunt or clinch left over from the bell.
    • A clinch draws both fighters together to a hold distance for the duration of the tie-up before the referee break.
    • The ring clamp stops a fighter at the corner pads instead of letting him stand behind the post, and the rest stools sit in front of the pads.
    • Protocol v3 (facing_x, facing_y, last_input_sequence); snapshots broadcast every tick; square ring with isotropic world mapping.
    • Client render clock with adaptive interpolation delay, edge-triggered input sends, local movement prediction.
    • The rendered fighter root follows the authoritative position at a fixed 6 m/s rather than a fixed step per frame, so low frame rates no longer leave fighters behind their own walking speed.
  2. Runtime pose system (web/hands/src/render/rig.ts, poser.ts, graph.ts)

    • Two-bone IK on the Texel skeleton with measured hinge conventions; feet plant and step; punches reach the opponent's live head; crosses square the hips and lift the rear heel; hooks sweep horizontally and every punch retracts straight back to the guard; fast gloves leave a short additive trail that fades toward its tail (off during the replay and under reduced motion); a clinch is a real tie-up (one fighter hooks over the arms, the other digs under around the ribs, heads on opposite shoulders) driven by the opponent's live position; the referee steps in to just over a metre while they hold and breaks them from there; the taunt is a showboat (rear glove on the hip, chest out, chin up, lead glove beckoning palm up) instead of two gloves held out front; hit reactions are spring impulses along the punch line; a glove touch at centre ring before the opening bell; authored supine and face-down falls with matching get-ups; seated corner pose on a stool between rounds with cornermen who lean in over the rope to work the corner; a cutman in a white shirt climbs in from the apron, crouches before the seated fighter and presses an enswell on the worse eye until the bell; the broadcast cuts to each corner in turn during the rest, the viewer's own first, from inside the ropes; stoppage celebration; skinned referee that counts, breaks clinches and waves the fight off.
    • ?model-lab=1&pose=...&trauma=heavy&cam=face is a pose lab used for screenshot review (pose=seated|celebrate|wave_off for the new poses).
  3. Injuries, gore, lighting, camera (web/hands/src/render/injury.ts, effects.ts, ring.ts)

    • Object-space injury shading on the skin materials: hematoma, swelling displacement, cuts, gravity-fed blood streams, wet specular, torso smears, jaw shear, a broken nose under heavy head trauma, and a transient compression of the struck cheek, chin, nose or ribs at the moment of contact.
    • Velocity-stretched instanced blood droplets, irregular canvas splats, teeth ejection, finishers that bake the real skinned head or glove surface into a rigid part.
    • Overhead key plus secondary key, cool rims, follow spot, restrained bloom, a soft vignette with light grain; closer, higher broadcast camera; off-white scuffed canvas; crowd tiers that react to contact and the finish; ropes that flex outward under a pinned fighter.
    • Portrait viewports widen the lens and pull the camera back so the ring stays framed.
  4. Feel and delivery

    • Layered impact audio (sub thump, glove thud, leather snap) with crowd roars and the ten-second clapper; viewer hit flash; a slow-motion knockout replay from a close orbiting camera (recorded snapshots around the final knockdown, impact spray and audio re-fired) then a short close-up on the beaten fighter's face, before the winner's celebration, the referee's wave-off and the result panel; round-start, rest and knockdown callouts; punches landed per round on the rest panel and per bout on the result panel; decisions labelled unanimous, split or majority from the scorecards.
    • Touch controls for Discord mobile (floating joystick, hand-split punch pads, hold modifiers and guards, get-up pads); compact scoreboard below 640 px. Long display names shrink to fit their plate before truncating.
    • Failures read as plain sentences a player can act on, with the code kept for diagnosis. The settings panel carries a copyable diagnostics snapshot (latency, frame time, render scale, GPU objects, graphics adapter, browser, last error) so playtesters can report problems with data.
    • Rematch from the result screen: the button counts down the room's result hold, then both fighters rejoin the same instance for a fresh bout with ratings carried over.
    • Waiting screen: a lone player shadow boxes with the empty seat hidden until the opponent arrives (previously both meshes overlapped at the ring centre before the first snapshot), with a one-line controls hint for keyboard or touch during the wait and countdown.
    • Adaptive render resolution: slow frame windows step the render scale and key shadow map down, fast windows step back up, and a downscale that yields no gain is reverted with exponential backoff so CPU-bound clients keep full resolution; at the bottom of the scale bloom and the key shadow switch off. Coarse-pointer devices cap the base pixel ratio at 1.5.
    • Textures re-encoded as JPEG: bundle 4.7 MB / 3.07 MB gzip at baseline is now 2.36 MB / 1.28 MB gzip.
  5. Verified end to end without Discord

    • scripts/hands_e2e_server.py runs the production server with a development auth backend (real tickets, rooms, websocket protocol, engine and persistence); the client's ?e2e=1 switch (Vite development builds only) uses the real bootstrap and token routes with a local handshake code. Two headless Chromium players authenticate, join a room, fight with keyboard input and receive the decision with scorecards and rating changes. scripts/hands_e2e_scenarios.js covers a knockout with the private get-up rhythm through to the KO result, a mid-round connection drop and resume, two rounds with the corner rest, a read-only spectator, touch input from an emulated phone, a key-masher at several hundred presses per second, both players behind a TCP proxy that delays every frame 110 ms each way, a full-length three-round soak (zero heap growth on both clients over 342 seconds, input latency between 5 and 45 ms), a backgrounded tab (one page frozen for eight seconds through the DevTools lifecycle state rejoins the live clock on resume), a rematch (two bouts back to back through the button), and a rematch loop (four bouts back to back with flat heap and constant GPU object counts across the renderer rebuilds), and a clinch (both players close in, one ties up, and the page records the separation: 76 units on entry, 60 through the hold, 150 after the referee break); all pass with no client or server errors.
    • This found and fixed two ways a slow machine was thrown out of a bout: the renderer was built before the socket authenticate frame (so model parsing and shader compilation could miss the server's handshake window, authentication_timeout), and an input whose client_tick lagged the snapshot by more than the window was treated as a fatal protocol violation (invalid_input, forfeit). The renderer is now built on welcome, fighter materials compile through the asynchronous compile path, and the informational client tick is clamped instead of rejected. A third bug appeared only behind a real 220 ms round trip: inputs still in flight when the bout ended reached the room after the result and were answered with a fatal match_complete close, so the player saw an error instead of the scorecards; the room now drops late inputs. The fighter HUD shows the measured input acknowledgement latency (send to first acknowledging snapshot), which reads about 20 ms on loopback and about 250 ms through the 220 ms proxy.

Measured on a real GPU

Apple M4 through ANGLE Metal, headless Chromium with the GPU enabled (the earlier numbers in this description came from the software renderer).

Scene Result
Fight cycle, 30 s, 1280x720 at 2x density 60 fps locked, 99th percentile 16.8 ms, no frame over 33 ms (119 fps uncapped)
Same with the CPU throttled 4x 60 fps, one 50 ms frame
Corner rest with seven skinned figures 60 fps, no long frames
Phone viewport 390x844 at 3x, CPU throttled 4x 60 fps, worst frame 33 ms
Startup to first frame with fighters 0.36 s (0.6 s at 4x throttle, 0.8 s at 6x on the phone viewport)
Input acknowledgement latency, real two-player stack 12 to 20 ms on loopback, about 240 ms behind the 220 ms delay proxy

The production bundle was also run under the production security headers, which the development server does not send: the pose lab from the live server and the replay lab from the test server rendered the full scene with no Content Security Policy violations. E2E_GPU=1 runs the scenario driver on the GPU. The knockout, rest, latency, touch and clinch scenarios pass there with no client or server errors, and the real frames exposed presentation bugs that are now fixed: the loser was already headless in the knockout replay, the finish close-up aimed at an empty neck, the replay lost the loser as he fell and cropped both fighters on portrait phones, a rope could fill the lens on close shots, the knockdown count and round panels hid the fighters, the status line and Rematch button covered the round clock, the round clock jumped to the count's own timer during knockdowns and foul timeouts, the phone scoreboard labels collided, the result panel cut the verdict and names on phones, and stools and cornered fighters stood behind the corner post.

Responsiveness under network delay

Measured with the new response scenario: the real client and server behind a TCP proxy that delays every frame, rendered on the GPU, timing each key press to the first rendered frame that shows it.

Each way / round trip Own punch starts Own step starts Opponent sees the punch Own punch jumps back
0 ms / 0 ms 5 ms 17 ms 79 ms never
60 ms / 120 ms 8 ms 12 ms 185 ms never
110 ms / 220 ms 10 ms 16 ms 272 ms never

Two bugs found this way are fixed. Stepping after a punch used to wait for the server (138 ms with no delay, 320 ms at a 220 ms round trip) because the client treated the engine's retained punch as still in progress. And the player's own predicted punch used to snap back when the server confirmed it (2 to 3 ticks locally, 8 to 9 at a 220 ms round trip), so a jab extended, jumped back and replayed. The punch now starts on the key press, stretches its startup by the measured latency so the glove arrives when the hit is shown, and never goes back.

Running the key-masher at a real frame rate found a third: the client could exceed the server's 60 inputs a second and was disconnected with a fatal error. The client now caps its own sends at 50 a second without losing a press, and the server ignores a burst above its limit instead of ending the bout (frames above 180 a second are dropped; the bout only ends if that lasts about 3 seconds or passes 720 frames within one second).

Independent review

Two fresh agents reviewed the day's riskiest changes read-only and reproduced their findings with throwaway tests and scripts. Every finding is fixed, with a test that fails on the old code.

Client (prediction, replay, overlay): 10 findings. A second punch pressed before confirmation replaced and then rewound the punch in flight; a follow-up during recovery was dropped; a punch the server turned down played in full; a late confirmation played a punch twice; predicted timing ignored fatigue and the lead-hand jab; movement was predicted while a punch held the fighter; the knockout replay stuttered; the replay camera could sit between the fighters near the ropes; reduced motion switched on mid-replay still severed the head; the overlay showed over the finish before the result arrived. Also glove trails allocating every frame and edge sends starving the periodic flush.

Engine and server (corner limit, input bursts): 5 findings. About 100,000 fuzzed fight ticks found no ring escape and no overlap. A cornered winner never walked to neutral distance after a knockdown; two fighters pressing into a corner never settled; fighters meeting at an angle were thrown up to 22 units apart; sliding along a corner pad depended on the direction and reported zero velocity; after a burst the server dropped the newest input frames and accepted nothing for up to a second. On the reviewer's own scripts, corner pairs now settle in 288 of 300 random scenarios (246 before the corner limit existed) and neutral distance is reached in 300 of 300.

Second review: server, engine and netcode

A fresh reviewer went through everything server-side and the netcode (engine, protocol, rooms, server, auth, the development-only end-to-end path, and the client's send pacing, render clock and prediction). It found no security problem, confirmed that the ?e2e=1 path and the development authenticator cannot be reached in a production build or on the production server, and confirmed the engine is deterministic. It found ten bugs, nine of them reproduced with throwaway tests; a later review of #294 found two more problems that were already present here. All are fixed in f7a989f, each with a test that fails on the old code.

  1. Smooth rendering never came back after a reconnect. The server stops stepping while a fighter is disconnected, and the client's clock estimate could only drift down 0.3 ticks a second, so after any pause every frame showed the newest snapshot (no interpolation) for minutes. The clock now resyncs on welcome and resumed and re-seeds itself after four late snapshots. Measured on the real stack: after a resume, 0 of 121 frames are stuck on the newest snapshot (121 of 121 before).
  2. Instant sends on a key press never fired during a fight. Their limit was compared against every send, including the regular 30-a-second flush, so each press waited up to 33 ms. Edge sends now have their own window.
  3. Rematch could end on a fatal error while the old room was still closing. The room is released before it stops taking rejoins, and the client retries room_closed during a rematch.
  4. A punch's recovery could be cut short for a follow-up the fighter could not afford, and after a parried punch. The cancel now needs the stamina for the follow-up and a punch that was not parried.
  5. Input pacing survived a reconnect, so a reloaded client could be thrown out on its first frame. It resets with each new connection.
  6. The player's own predicted movement ignored the ropes, the corner pads and the opponent. It is clamped with a port of the engine's ring projection and kept clear of the opponent.
  7. Client punch prediction disagreed with the engine on affordability and, in 342 of 1,344 punch and fatigue combinations, on timing. It now matches a table generated from the engine.
  8. Fighters walked through each other on the way to their corners. They are separated now.
  9. The bell did not clear evasion, counter and combo windows, so a slip at the bell carried a counter bonus into the next round.
  10. Turning is applied before and after footwork each fight tick. Behaviour is unchanged; it is now documented and pinned by a test.

Also: a flood of input frames after a stalled uplink is dropped rather than fatal (fatal only after 3 s of flooding or at four times the limit); the development server defaults to port 8091 instead of the live 8080.

GPU memory. Every rematch leaked about 15 MB in the shared WebGL context: the app reuses one canvas, and destroying a renderer left the module-cached fighter textures and model geometry, every post-processing pass and the skeleton bone textures alive. They are freed now (about 0.1 MB per rebuild left inside three.js), and the rematch-loop check counts live GL objects instead of renderer.info, which restarts at zero with every renderer and so could never see it.

Shader pre-compile. It compiled the fighters' shaders for drawing straight to the screen while the scene draws into the composer's render target, so none of them was ever used and they all compiled on the first real frame. The composer's target is bound for the pre-compile now.

End to end on the real GPU: reconnect, rematch, rematchloop (texture memory flat at about 62 MB over three rematches), rest, latency and response all pass with no client or server errors.

Live playtest checklist

Status (2026-10-07): the bot is not running. The host rebooted on 2026-09-29 and nothing restarted the bot or its quick tunnel, so this branch is not live. To play it in Discord, follow the procedure below with a fresh cloudflared tunnel --url http://127.0.0.1:8080 and point the Activity URL mapping / in the Developer Portal at the new tunnel host (it changes every time the tunnel starts).

The procedure that was used, for reference:

  1. Stop the running uv run intelstream (one bot token must not run twice).
  2. cd ~/Development/intelstream-hands-realism && env -u ANTHROPIC_API_KEY uv run intelstream (the shell profile exports an Anthropic key that the API rejects and that would override the valid one in .env) (the worktree has a copy of .env, so it uses the same application, guild, port and public origin; the static bundle is already built into src/intelstream/hands/static/).
  3. Confirm curl -s http://127.0.0.1:8080/healthz and that the public origin's URL mapping still reaches it, then launch Hands from Discord's App Launcher and have a second member press Play now.
  4. Watch for: input feel on desktop and phone, the glove touch and round callouts, hit reactions and blood, a clinch (B / left stick press) with the referee break, a taunt (M), a knockdown with the private get-up rhythm, the corner rest with stools and cutmen, the knockout replay and result panel, and the Rematch button.
  5. If anything looks wrong, open Settings → Copy diagnostics on that player's client and paste it into the server; it carries latency, frame time, render scale, GPU counts, adapter, browser and the last error.
  6. To go back, stop the worktree process and start uv run intelstream from ~/Development/intelstream again.

Validation (per push)

uv run ruff check ., uv run ruff format --check ., uv run mypy src/, uv run pytest tests/ (1922 passed), uv run python scripts/hands_replay.py --check, npm run typecheck, npm run test:run (245 passed), npm run build (static scan passed). Visual review via headless Chromium screenshots and frame strips of the pose lab, the dev fixture (?fixture=1, &finisher=head|hand, &phase=rest, &pin=1), and emulated phones in both orientations for the touch layout.

…er clock

Engine: fighters carry a fixed-point facing vector that turns toward the
opponent at a limited rate, and the hit test measures forward and lateral
distance against it, so punches connect when fighters are offset along ring
depth. Queued actions survive the current punch and dispatch when it ends
instead of expiring six ticks after input; a landed punch can cancel the
second half of its recovery into a manifest-listed combo follow-up. Knockdowns
no longer teleport the downed fighter on get-up: the standing fighter walks to
neutral distance during the count. Snapshots broadcast every tick.

Protocol v3 adds facing_x, facing_y, and the last processed input sequence.

Client: the snapshot buffer estimates the server clock from arrival times and
renders on a continuously advancing, adaptively delayed tick instead of holding
each 15 Hz pose for four frames; punches and movement are sent on the input
edge; the viewer's own fighter is predicted ahead with a port of the movement
integrator so footwork responds immediately; the skinned rig yaws from the
authoritative facing vector without the extra blade offset.
Replace the retarget-baked clip set with a runtime pose solver that drives
the skinned rig directly in world space. Every frame the graph authors a
character-space pose (bladed stance, torso chain yaw/pitch/roll, hand and foot
targets with elbow and knee poles, hand orientation) and the solver applies it
with two-bone IK using the rig's measured hinge conventions.

Mechanics now come from the fighter state instead of clips: feet plant on the
authored stance and take real steps when the authoritative root moves,
punches extend from the rotated shoulder to the opponent's live head or torso
so contact aligns with the server's hit tick, crosses square the hips and
lift the rear heel, hooks sweep horizontally with the elbow out, uppercuts dip
and drive, guards and slips and weaves and pulls move the head off the line,
hit reactions are spring-damper impulses along the incoming punch line, and
knockdowns fall to an authored lying pose and rise through all-fours and a
knee. Southpaw mirrors the authored stance.

The GLB now ships mesh and skin only (no clips, no marker export), the
procedural capsule rig and its animator are removed, the referee is a skinned
instance with a shirt texture and a counting posture, the model lab becomes a
pose lab, and the simulation ring is square so the world mapping is isotropic
and reach reads the same in every direction.
…dcast lighting

Injuries are shaded in the head and body materials from anatomical sites in
the mesh's bind space instead of spheres parented to bones: hematoma
discolouration, swelling displacement around the eyes and cheeks, brow and
cheek cuts, gravity-fed blood streams from cuts, nose, and mouth, wet
specular where blood sits, neck and shoulder smears on the torso, and the
arcade jaw dislocation as a lower-face shear.

Blood droplets are velocity-stretched instanced spheroids instead of square
points, canvas splats use irregular blob textures, heavy head contact ejects
teeth that bounce and rest on the canvas, and the decapitation and
dismemberment finishers bake the fighter's actual skinned head or glove
surface into a rigid part with a cut cap.

Lighting drops the five-spot pastel wash for an overhead key, a secondary
key, two cool rims, a follow spot, and a low hemisphere, with bloom limited
to bright fixtures. The broadcast camera moves in and up so fighters fill
the frame above the near ropes. The dev fixture gains finisher triggers and
a debug probe for headless review.
…UD callouts

Punch impacts are synthesized as a sub thump, a glove thud, and a short
leather snap scaled by damage, with body shots pitched lower; knockdowns and
finishes trigger a crowd roar that decays over several seconds. The viewer's
own hits flash a red vignette, knockouts play the fall in slow motion for two
seconds, the HUD announces knockdowns with the downed fighter's name and the
round during the countdown, and flesh gibs are smaller. Documentation points
the old clip-era report at the current plan.
Coarse-pointer clients get on-screen controls: a floating joystick on the
left half for footwork, four punch pads whose left or right half selects the
hand, hold buttons for body shots, power, and both guards, and get-up rhythm
pads during a knockdown. The generator re-encodes the five embedded textures
as JPEG through pngjs and jpeg-js, taking the production bundle from 4.4 MB
to 2.35 MB (1.3 MB gzip) so the Activity opens faster. The ring canvas is a
scuffed off-white surface that shows blood, with the key light rebalanced.
The punch pads overlapped the right fighter's guard and poise bars on a
landscape phone and the modifier row sprawled across the ring. The pads now
stack above the scoreboard with the modifiers in a two-by-two block. The dev
fixture mounts the touch overlay on coarse-pointer clients so the layout can
be reviewed over a live match.
The renderer now averages frame intervals over one-second windows and
lowers the render scale (and the key shadow map) one step at a time when a
window is slow, raising it again after three fast windows. A downscale that
does not make the next window at least ten percent faster is reverted and
further attempts back off exponentially, so a client capped by its display
or the CPU keeps full resolution instead of blinking.

The crowd now has an excitement level fed by contact events and the
finish: bounce and sway grow with it, camera flashes fire more often, every
seat animates each frame instead of a rolling subset, and the seat
materials carry a faint emissive so the tiers read as a crowd behind the
ropes instead of vanishing into the dark.
During the rest phase the engine walks each fighter to their own corner
(blue near-left, red far-right) at a fixed deterministic speed, snaps them
onto the corner and turns them to face the opponent; entering rest also
clears any attack, held defense, stun, taunt or clinch left over from the
bell. Facing updates now handle exactly anti-parallel directions, which
previously blended through the origin and never turned.

The client seats a resting fighter on a stool once it has been still for a
moment: hips drop onto the seat, gloves rest on the knees, the torso and
head breathe, and the stool (a child of the fighter root) shows in the
corner until the bell. The dev fixture accepts ?phase=rest and the pose lab
accepts pose=seated for review. Rebuilt static bundle included.
…r celebrates

The final result panel used to cover the ring the instant the server's
final message arrived, which on a stoppage is the same moment the
slow-motion fall starts. Stoppage results now wait 3.6 seconds of wall
time before the panel is drawn; decisions still show immediately. The
winner of a stoppage raises both gloves overhead for a few seconds and
settles back to the guard. The dev fixture's finisher mode sends a
synthetic final so the sequence can be reviewed; the pose lab accepts
pose=celebrate. Rebuilt static bundle included.
Narrow viewports (Discord mobile in portrait) now widen the lens a little
and pull the broadcast camera back so both fighters and the ring stay in
frame instead of being cropped by the vertical field of view. Below 640 px
the scoreboard plates share the bottom edge and the round card sits under
the top bar, so the names no longer collide with the clock. Coarse-pointer
devices start at a 1.5x pixel ratio cap to save fill rate.

The HUD tracks punches thrown and landed per fighter per round from the
event stream and shows the landed counts on the rest panel between rounds.
Rebuilt static bundle included.
…row panels

The rope materials displace their vertices outward in the vertex shader
around whichever fighter is leaning into them, weighted by rope height,
faded toward the corner posts and driven by two contact uniforms the
renderer updates from the fighters' world positions. The dev fixture's
pin=1 mode places a fighter on the ropes for review.

On a stoppage the referee sweeps both arms crossing overhead to wave the
fight off (pose lab: pose=wave_off). The knockdown and center panels now
shrink to fit viewports narrower than their fixed widths. Rebuilt static
bundle included.
A knockdown caused by a hook now pitches the fighter forward into a prone
pose (torso flat, arms by the head, legs extended behind) instead of the
supine fall, and the get-up sequence starts from whichever pose the fighter
landed in. The pose lab accepts pose=knockdown&fall=prone. README notes the
review harness options and the current bundle size. Rebuilt static bundle
included.
pip-audit flagged anyio, soupsieve and pip in the lock file; npm audit
flagged transitive nanoid and undici plus the vitest mocker advisory. Bumps
anyio to 4.15.1, soupsieve to 2.10, pip to 26.2.1, vitest to 4.1.11 and the
transitive nanoid and undici to their patched releases. Both audits are
clean and all suites pass.
@user1303836
user1303836 marked this pull request as ready for review September 29, 2026 03:16

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your trial has ended. Reactivate Greptile to resume code reviews.

…scord

Playing the real client against the real server exposed two ways a slow
machine got thrown out of a bout:

- The client built the renderer (model parse, shader compilation) before
  its socket authenticate frame could go out, so on a slow machine the
  frame missed the server's five-second handshake window and the player
  saw authentication_timeout. The renderer is now built when the welcome
  message arrives, and the fighters' materials are compiled through the
  renderer's asynchronous compile path before they enter the scene.
- The server treated an input whose client_tick lagged the snapshot tick by
  more than the allowed window as a protocol violation, closing the socket
  and forfeiting the match. The tick is informational, so it is now clamped
  into the plausible window instead.

scripts/hands_e2e_server.py runs the production server with a development
auth backend (real tickets, rooms, websocket, engine and persistence), and
the client's ?e2e=1 switch, present only in Vite development builds, uses
the real bootstrap and token routes with a local handshake code. Rebuilt
static bundle included.
…result hold

scripts/hands_e2e_scenarios.js drives two headless players through the
real client and server: a knockout with the private get-up rhythm through
to the stoppage, a mid-round connection drop and resume, two rounds with
the corner rest, a read-only spectator, and touch input from an emulated
phone. The default celebration now lasts 4.4 seconds so the winner is still
posing when the held result panel appears. Rebuilt static bundle included.
A key-masher pressing several hundred keys per second stays connected and
the bout completes; a player on an emulated 220 ms link fights a full round
to a stoppage with no errors on either client or the server.
A stoppage's result used to arrive with the fighter already down, so the
finish slow motion showed nothing of the punch that ended the bout. The
renderer now keeps the last sixteen seconds of snapshots; when a stoppage
final arrives it replays the window around the last knockdown at 0.4x from
a close over-the-shoulder camera that slowly orbits, re-fires the impact's
spray, head snap and audio at the moment of contact, shows a replay tag and
hides the count panel, then returns to the live view for the celebration,
the referee's wave-off and the held result panel. Reduced motion skips the
replay. Rebuilt static bundle included.
…put latency

Behind a genuine 220 ms round trip (the scenario driver now routes both
clients through a delaying TCP proxy, since Chromium's network emulation
does not delay websockets) the inputs still in flight when the bout ended
reached the room after the result and were answered with a fatal
match_complete close, so the player saw an error instead of the scorecards.
The room now drops inputs that arrive after completion.

The network controller measures input acknowledgement latency (send time of
a sequence to the first snapshot acknowledging it) and the fighter HUD
shows it; the development build exposes it to the scenario driver, which
reads about 20 ms on loopback and about 245 ms through the proxy. A single
finishing pass adds a soft vignette and light grain before tone mapping.
Rebuilt static bundle included.
The injury shading gains a transient compression around the struck site
(cheek for hooks, chin for uppercuts, nose or mouth for straights and jabs,
ribs or solar plexus for body shots) that releases exponentially, so the
knockout replay and heavy hits show the face giving under the glove. The
pose lab holds it with dent=hold. The scenario driver gains a full-length
three-round soak that samples heap size, input latency and render scale.
Rebuilt static bundle included.
The soak now paces the players so a bout lasts all three rounds; a run
shows zero heap growth on both clients over 342 seconds with input latency
between 5 and 45 ms. The background scenario freezes one player's page
through the DevTools lifecycle state for eight seconds, as a phone would
when the app is backgrounded, and checks the client rejoins the live clock
on resume and both players receive the result.
On a referee_break event the referee pushes both arms out and apart at
chest height to separate the fighters (pose lab: pose=break). When ten
seconds remain in a round the audio cracks the wood-block clapper twice,
once per round. Rebuilt static bundle included.
After a final, fighters get a Rematch button that counts down the room's
result hold and then re-authorizes into the same instance, where the server
opens a fresh room once both players return. If the held room only replays
the previous final, the client retries every few seconds and gives up with
a clear error after six attempts. The scenario driver plays two bouts back
to back through the button; ratings carry into the second bout. Rebuilt
static bundle included.
The result headline now reads the scorecards: decisions are labelled
unanimous, split or majority, and draws likewise. Rebuilt static bundle
included.
Two skinned cornermen in team shirts stand on the apron outside their
fighter's corner during rounds and, during the rest, step in and lean over
the top rope to work on the seated fighter. They share the referee's pose
mode and are compiled with the fighters before entering the scene. Rebuilt
static bundle included.
Before any snapshot arrived both fighter meshes sat untouched at the ring
centre, so a player waiting alone saw one fighter overlapping the other
with the referee against his back. The renderer now drives an idle scene
from the seat order in the welcome message: a lone player warms up in guard
facing the camera with the empty seat hidden, and once both players are
known they wait in guard at their starting marks. When the resolution
scaler bottoms out, bloom and the key shadow are switched off as well.
Rebuilt static bundle included.
user1303836 and others added 20 commits October 7, 2026 08:48
The referee, cornermen and cutmen are built from the fighter model, so
each one had the fighters' clearcoated skin, with the 12-site injury
loop in both the vertex and fragment shaders. They also drew into the
key light's shadow map, though officials never take damage. And because
the sweat sheen was applied to every skin material, each frame gave the
officials' matte shirts a clearcoat of 0.25 to 0.65. That also meant the
clearcoat-free shirt program compiled at startup was never used.

An official (a boxer dressed in a shirt) now gets standard materials:
no clearcoat and no injury shader, so they share programs with the
fighters' gear and the startup compile still covers them. They cast no
shadow; the blob shadow already grounds the referee. Their injury state
is still there for the graph to update, but it no longer patches a
shader. The sweat sheen only touches clearcoated skin, so the officials
stay matte. Fighters are unchanged, and an official's materials are
still all freed with him.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ot strides

The footwork took the walk's velocity from its change since the last
frame. A client whose first countdown snapshot arrives partway through
the walk (a reconnect, a spectator, a slow first snapshot) saw the
whole offset in one frame. It read that as a 25 m/s stride and planted
a foot 0.9 m ahead of the fighter. The velocity is now capped at a
brisk walk (1.5 m/s; the walk itself peaks at about 1), so he steps
across normally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…its first frame

On a hook's or uppercut's first frame the windup point was already 60% of
the way out, and the hand's turn and the elbow's pull switched to the
punch's at once. The hook's glove jumped 0.16 m and its elbow 0.30 m in a
single 60 fps frame, and the uppercut's glove 0.26 m. The part of the
windup held until the punch travels now eases in with the windup itself,
and the hand's turn and the elbow's pull blend over twice the windup. While
the pull turns, the elbow swings out in proportion to how far it has to
turn, so it never lines up with the arm and flips. First frames now move
under 5 cm and build up, and no frame of the windup is faster than the
punches move near contact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… current

Every tick sent each fighter and spectator an uncompressed snapshot of
about 2.1 KB, roughly 65 KB/s per viewer, and a client slightly slower
than that fell seconds behind: through a 48 KB/s link a spectator's view
was 5.6 s old after 25 s, because the 16-message outbox only noticed once
aiohttp's buffers had filled.

The server now negotiates permessage-deflate. State updates share the
connection's deflate context, which takes a scripted bout's median
snapshot from 2,154 bytes to 160 (65 KB/s to 5 KB/s). Through the same
48 KB/s link the view stays current at 30 snapshots a second using
3 KB/s. Frames that carry a reconnect ticket (welcome and ticket) are
written uncompressed, so the ticket never shares a context with
player-chosen text. aiohttp's per-message option would deflate them with
a fresh context the client's inflater cannot follow, so the adapter
builds that frame itself.

Each connection now keeps only its newest unsent snapshot, carries the
events of the snapshots it skipped into it, and holds it while the
transport already buffers more than about two frames. A connection that
misses outbound_queue_size snapshots in a row is dropped, as one with
that many queued updates was before. The reconnect churn test allows the
one queue slot a pending snapshot now has to itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review asked for a client capped at about 40 KB/s. A spectator that
offers permessage-deflate, as browsers do, now watches through a proxy
capped at 40 KB/s and must stay within six ticks of the room's engine.
Before snapshots were deflated it fell 32 ticks behind within three
seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The lying poses never looked at the ring. With his back to the ropes
(the pinned fighter, who is usually the one to go down), a fall onto
the back put his head at 2.99 m with his centre at 1.83 m, past the
rope line at 2.46 m. With his centre at 2.44 m the head lay on the
apron, and at the engine's limit of 2.82 m the body reached past the
platform edge. The replay's close camera follows exactly that fall.

When the fall starts, the punch's own fall is checked against the
rope line, using the reach of the head, shoulders, gloves, feet and
hips. If it fits it is played as before. Otherwise the variant that
needs the least moving in from the ropes is played: untwisted,
twisted the other way, or falling the other way round. Each change
from the punch's fall counts as some extra distance, so a fighter
with his back to the ropes falls forward rather than being dragged
off them. The landing is moved in only as far as it must be. Getting
onto all fours brings him back over his own position.

Now, with his back to the ropes at 1.83, 2.44 and 2.82 m, he lands
face down toward the centre, and the furthest vertex is at 2.27 m.
Facing the ropes, a hook lands him on his back at 2.41 m. In 60
random knockdowns near the ropes and corners, nothing landed past
2.44 m.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er's stun

The rise only started when is_downed cleared, and then took 1.7 s.
The engine is back in the fight on that tick: the fighter can walk at
once, punch after the 20-tick (0.67 s) stun, and be hit within about
half a second. On the client he stayed down for 1.83 s and his head
stayed below 1.4 m for 1.42 s. A jab the server started 0.7 s after
the get-up never left the kneeling pose, and walking slid the
kneeling body across the canvas.

While he is down, the get-up now follows the get-up meter that is
already in the snapshot. Each good press pushes him further from
lying toward all fours and up to one knee, and a bad press lets him
sink back. When the server lets him up, the rest of the rise takes at
most 0.5 s, or 0.2 s once he walks or throws (rolling off the back
onto all fours is never hurried past a safe speed). Now, after two
good presses he is already up off the canvas, his head is above
1.4 m within 0.55 s of the get-up, a jab at 0.7 s comes out at full
reach, and walking off finishes the rise within a quarter of a second.
Reactions to hits during the rise blend in with the standing pose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he release

Movement was predicted only the interpolation delay ahead, from the input
held now, smoothed at 14/s. The server applies a press about a round trip
later, so the prediction ran out first: at a 220 ms round trip the fighter
stepped 15 units, crawled at 45 units/s until the server's motion showed,
then kept walking 44 units after the release. The lead is now the measured
input latency plus the interpolation delay, and over that lead the
prediction replays the input the server applies at each tick ahead, the
input held when that tick's flush went out (averaged around it, since the
flush's phase is unknown), rather than assuming the current one was held
all along. The 14/s smoothing, which lagged any moving target, becomes a
limit of 2.5 times walking speed on how fast the offset may change, so real
motion passes untouched and a correction slides. The lead itself eases to a
new estimate. Simulated behind a round trip with the real snapshot buffer,
the fighter now covers 28 units in the first 150 ms and walks at 175 to 250
units/s through the old stall window, at 120 and 220 ms. After the release
it travels no further than the server's own stopping distance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only the player's position was predicted. The guard, slips, weave and pull
were posed from the delayed snapshot's defence, so holding guard raised it
a round trip plus the interpolation delay later, about 290 ms at a 220 ms
round trip, while a punch started on the press. In the fight the player's
fighter is now shown in the guard held now. The server's defence stands
where it overrides the held guard: none while stunned or taunting,
unchanged in a clinch, and an evasion it is playing. A slip, weave or pull
pressed while the fighter is free and can pay the engine's 25 stamina is
shown at once and held for the lead plus EVASION_TICKS, so it ends as the
server's copy does on screen. Its frame is looked up through
inputSequenceOf; if the server then neither plays it nor holds it, or a
stun, clinch or phase change cuts in, it is dropped. While it plays the
replayed movement keeps the feet still. A frame where only the defence
differs is no longer skipped. A test pins EVASION_TICKS and the stamina
gate to the engine source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Coalesced snapshots keep a stalled socket's transport below aiohttp's own
pause point, so the test lowered that transport's high-water mark and
waited for a pause. On Windows that pause can last only until the kernel
takes the write in flight, and the test then passed on the code it was
written to catch. It now also waits until more than the room's snapshot
backlog is stuck in the transport, which only happens once the peer's
kernel buffers are full, and fails again on 37a3df6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When is_downed came back while the fighter was getting up, the new
fall started from the full standing pose, so for one frame he snapped
upright and then fell again. A fighter put back down mid-rise jumped
from a head height of 0.91 m to 1.56 m (hips 0.52 to 0.80 m) in a
single frame. The engine resumes the fight while the client is still
rising, so a follow-up flurry can easily cause this.

The pose writeDown last wrote, and how much of the standing pose was
in it, is now kept. A fall that cuts into a get-up starts from it,
including the arm and knee directions, instead of from standing. The
same knockdown in the middle of a rise now moves no bone more than
0.12 m between frames (0.77 m before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…apping when he stands

stepFeet is skipped while the fighter is down, so the planted feet
kept their world positions from before the knockdown. The last stage
of the get-up blended toward a standing pose that read those stale
feet against the current root and facing, and on the frame the rise
ended the feet were planted on the stance again all at once. When
the engine turned him toward an opponent who had moved during the
count, the ankles jumped 0.50 m in that frame. Walking during the
rise did the same.

The feet are now planted on the stance when the rise starts and step
as usual while he gets up, so the standing pose he blends into is
already where his feet are. On the canvas and getting up, he also
turns more slowly, since he is turning his whole body on the ground.
In the same turned get-up, neither ankle now moves more than 0.06 m
between frames (0.50 m before).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e direction

The suite mostly checked end states, so none of the problems from the
review failed a test: the re-knockdown snap, the foot snap at the end
of the rise, shoes deep in the canvas, knees bending away from the
pelvis. One fighter now steps frame by frame through idle, a jab and
a straight, a face-down knockdown and a get-up driven by the meter, a
knockdown on his back that is cut into by a second knockdown
mid-rise, another get-up, the corner stool and the bell, and a taunt.
On every frame it checks that no bone moves more than 0.15 m (9 m/s;
a straight's glove peaks at 0.12), that no skinned vertex goes lower
than -0.02 m, and that the knees bend along the pelvis' forward axis
(at least +0.25; the lowest is +0.38 on the stool). It takes about
2.5 s.

Hook and uppercut are left out of the punch list for now: their first
frame still jumps the glove by up to 0.3 m in applyPunch, which the
prediction fixes cover. Adding them is a one-line change once that
lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The prediction branch gave the renderer an inputSequenceOf option so a
punch the server refused is taken back as soon as the fighter's
last_input_sequence passes the frame that carried it, and the network
branch records that frame for every press. Nothing connected the two,
so the client still fell back to the old timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ick taunt

Hooks and uppercuts were left out of the sweep until a punch's first
frame stopped jumping the glove; that fix is now merged, so all four
punches are swept.

The sweep's taunt lowered the count once per frame, twice as fast as
the engine's 30 Hz tick. The beckon is now timed from the render tick
and only re-reads the count when the two disagree by more than a few
ticks, so the doubled count forced a re-read and a 0.19 m glove jump
that live play never produces. The count now steps every second frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test pinned the old dent formula, a centimetre per 220 damage, with
a 420-damage straight. The fall fixes scaled the dent to the engine's
real range (full depth at its hardest hit, about 150), so 420 clamps to
the maximum and the test failed once both branches were merged. It now
lands a 90-damage straight and checks the scaled depth.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
user1303836 added a commit that referenced this pull request Oct 7, 2026
Brings the 75 review fixes from #293 (server close paths and deflate,
engine stun and clinch rules, one Discord SDK per page, touch evasion
controls, movement and punch prediction, the reworked fall and get-up,
officials, renderer GPU and gore fixes) onto #294.

Where both sides changed the same behaviour:
- Stuns: a stun still running can't be restarted (#293), and #294's
  clear window, rocked bypass and 45-tick chain cap stay. Re-stuns in
  champion bouts drop from 30% to 0%, stunned time from 8.6% to 5.8%.
- Knockdowns: #294's physics ragdoll plays the fall; at the get-up the
  graph fits #293's authored down pose to the ragdoll and runs its
  meter-driven rise. The ragdoll's blend now really blends (it slerped
  a quaternion into itself).
- Prediction: #293's held-input movement replay keeps #294's stunned
  speed and the styles' footwork, evasion and cancel rules.
- Finish: #293's wait-for-the-finishing-punch is ported into #294's
  no-replay finish; #294's replayed stoppages are unchanged.
- Renderer: contact and mouth points stay on their own vectors (no
  camera lurch) with #293's spray direction; the shader warm-up uses
  #294's canvas blood and severed parts and now also builds their
  shadow variants, so the first decapitation compiles nothing.
- Rooms: the uncompressed welcome and #293's pause-aware held frame
  work with #294's computer rooms and style pick.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@user1303836
user1303836 merged commit 94973f6 into main Oct 7, 2026
6 checks passed
user1303836 added a commit that referenced this pull request Oct 7, 2026
#293 was squash-merged as 94973f6, whose tree is identical to
7987a51, which this branch already contains. This records main as merged
without changing any file, so #294 can merge into main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
user1303836 added a commit that referenced this pull request Oct 7, 2026
… wave-off waits for its punch

setFinal now ignores the result it is already showing (same match id):
a second copy would rebuild the decision's ceremony from the fighters'
current places, hide the card again, restart the crowd and the
commentary's "to the scorecards" line, or start a stoppage's knockout
replay over. The client only gets one final per connection today (the
network goes terminal on it, and every new connection rebuilds the
renderer), so this is a guard, not a fix for something seen.

The merge of #293 brought its pending finish into this branch's
setFinal: with no replay (reduced motion), the wave-off, the winner's
celebration and the close-up wait for the render clock to reach the
result's tick, presented after that frame's contacts, half a second at
most. A frame test now pins that the referee's wave-off and the winner's
celebration do not start two ticks early and do start with the punch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
user1303836 added a commit that referenced this pull request Oct 7, 2026
…ads leave the top bar its taps

#293's slip, weave, pull and clinch buttons made the pad block 190 x 274
held upright and 324 x 196 on its side (measured in headless Edge with
the real stylesheet), but the caption slot still assumed one 216 x 196
column. At 320x568 a fight caption (y 136 to 187) ran under the new
SLIP and WEAVE row from y 176, and the "beside the pads" width,
width - 432, went negative on every phone held upright.

caption.ts now knows the block per orientation: upright it is one
column, so a caption that would reach it gives way; on its side the
caption stays beside the modifiers' column, or beside the moves too
once it reaches their height, and gives way rather than run narrower
than 200 px. The pads are left out of it once the bout is over, when
they are hidden.

On a phone on its side 350 px tall or less the block did not fit under
the top bar: at 640x320 and 600x330 its empty corner above the moves
took the taps meant for Controls and Settings, and at 568x320 BODY sat
over half of Settings, the moves covered the bottom 12 px of the round
card and GUARD and JAB covered the red corner's picture beside the
clock. The pad block now lets taps through its gaps (only the buttons
take them), a short landscape screen gets smaller pads (44 px, the
modifiers and moves 26 px, 4 px gaps, 112 px up: a 154 px block that
starts at y 54 at 320 px), and the clock pictures stay off a narrow
landscape HUD while the pads are up. Re-measured at 568x320, 600x330
and 640x320: every top bar button takes its tap, and no pad covers
the round card, a picture or the hint.

The Controls panel's Touch section came with the merge, and app.test
already checks its headings.

New tests: drawn captions stay clear of the measured pad rectangles at
13 phone sizes, upright and on its side, in every phase; caption.ts's
pad sizes are recomputed from style.css's own rules, so a change to the
pads fails them; the pad block passes taps between its buttons; the
clock pictures are left out at 568x320 with the pads up. The caption
and stylesheet tests fail on the old code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
user1303836 added a commit that referenced this pull request Oct 7, 2026
… on its side

During the countdown the touch hint moves to the stick side, but it
kept max-width: min(44vw, 300px), wider than the room left of the pads
on small phones. In headless Edge with the real stylesheet and touch
controls it covered the "L" half of JAB and HOOK by 35 px at 320x568,
12 px at 360x640 and 360x740, and 4 px at 375x667 and 375x812; with
#293's moves merged in, at 568x320 it covered WEAVE and CLINCH by 30 px.

Its width is now also capped by the room left of the pads: 100vw less
226 px held upright (the 190 px column, its 12 px margin, a 12 px gap
and the hint's own 12 px) and less 360 px on its side (the moves add
122 px and 12 px), less the side insets. Re-measured from 320x568 to
932x430: the hint overlaps no pad, modifier or move at any size (94 px
wide and 177 px tall at 320x568, 208 px wide at 568x320).

The test reads the hint's cap from style.css and checks it ends 12 px
short of the pads at 320 to 412 px upright and 568 to 932 px on its
side; it fails on the old rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
user1303836 added a commit that referenced this pull request Oct 7, 2026
…t reloads instead of crashing

#294 adds messages and fields both strict decoders reject from the other
side (select, style and cpu frames; style and record in the players;
style in every snapshot fighter; and in this branch final's punches, a
spectator welcome before the bell and two open seats) but PROTOCOL_VERSION
stayed 3. A tab still running the previous bundle when the server
restarted reconnected, passed authenticate and died on its first select
("unsupported server message type"), and its seat forfeited after the
grace.

PROTOCOL_VERSION is now 4 in protocol.py and types.ts. The client's
message types name the constant instead of a number, and the decoders,
encoders and bootstrap check use it. #293 already answers an authenticate
in another version with client_outdated in that version (which the old
window decodes) and makes the client offer Reload for it, for a newer
bootstrap and for a frame in another version; with the bump that path is
what a window from before #294 now takes: a version-3 authenticate gets
{"version":3,"type":"error","code":"client_outdated"} and close 4003
without spending its ticket, and its Retry button reads Reload.

Every protocol literal in the Python and client tests and the fixtures
names the constant (hand-written raw frames go through a small helper);
the outdated-client tests send the version before and after the current
one. The golden replay is regenerated: it records encoded snapshots, and
only their version field changed (engine checksums are untouched).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
user1303836 added a commit that referenced this pull request Oct 7, 2026
…rame

The merge of #293's fixes made KnockoutRagdoll.blend() slerp from the current
pose toward the stored one, instead of slerpQuaternions(from, bone.quaternion,
w), which copied `from` over the bone before reading it and so never left the
standing pose. The ragdoll tests only compared positions, so nothing caught it.

This one follows the chest and head bones through the first twelve frames of
a straight and a hook knockdown: the chest must start turning within the
blend-in, and no frame may turn it more than 8 degrees or move the head more
than 12 cm. With the old line the chest held still for five frames and then
turned 15 degrees as the head jumped 21 cm; now the chest turns at most 4.3
degrees and the head moves at most 7.8 cm a frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@user1303836

Copy link
Copy Markdown
Owner Author

Follow-up: the review items that weren't tied to a line

All 62 inline threads were fixed and resolved before this PR was merged. A few points in the review summaries weren't tied to a line. Those that weren't fixed here are fixed on #294, which rewrote the same engine and rooms code and merges into main next. Commits are on feature/hands-polish.

Engine (batch 4)

  • Golden replay coverage (6447c23): the replay now plays a boxer against a slugger through every rule:

    • an off-axis exchange that takes an angle;
    • a recovery cancel;
    • a rocked stun;
    • a clinch and the referee's break;
    • a foul with its warning and separation;
    • a clinch broken by the bell;
    • the rest with the walk to the corners;
    • a delayed body knockdown.

    hands_replay.py fails if any of those stops happening.

  • Turn-rate limit (de88cb8): facing turns at most 75°/s, and a fighter's own steps turn him with them. A full-speed circle leaves a man who stands 24° off after a second at 120 units, so his straight misses, while a man who steps with the circler stays square.

  • Facing through a cancel chain (de88cb8): facing holds only through each punch's startup and active ticks, so it follows the opponent between the punches of a combination.

  • Corner wobble and the clinch at the bell (44e3720):

    • The separating push stops at the corner pad: in 2,000 corner-pressing pairs, settled bounces fell from 2.6% to none.
    • The referee now breaks a clinch still on at any bell.

Rooms and server (batches 1 and 2)

  • Rematch seats (78e76d7, 5dd7b6a, 1ef5cbc):
    • The next room in an instance holds the last bout's fighters' seats for 60 s, so a relauncher can't take one.
    • Spectators get a "Next bout" button that follows the channel to the next room.
  • Rate limits behind Discord's proxy (4a22f6d): every player shared one address bucket. Sign-in buckets are now keyed on the address plus the Activity instance.
  • Flood description: corrected in this PR's description. Frames above 180 a second are dropped, and the bout only ends after about 3 s of that, or at 720 frames within one second.

Client tests (batch 2)

  • Masher test (3e49ec0): it now drives the client's real one-press queue and checks that the newest press is always the one sent.

Mobile performance (batch 3) needs a run on a real mid-range Android phone and an iPhone inside Discord, which hasn't been possible here. Measured instead:

  • Fake WebGL harness, 390x844: a fight frame issues 156 draw calls and 50 programs.
  • Shader compiles: no finisher compiles a shader after load.
  • Program switches: the low tier switches no programs.
  • Resolution scaler: it settles one step down instead of pumping.

user1303836 added a commit that referenced this pull request Oct 7, 2026
…s, physics knockouts, gore and a broadcast presentation (#294)

A computer opponent at three levels, five fighting styles picked before the bout, the corner between rounds, the count with a standing eight, the blind side, parries, guard breaks and body-shot knockdowns, rebalanced bouts that go rounds, physics knockouts with replays, finisher gore and canvas blood that builds over a bout, and a broadcast presentation with commentary, a ring announcer, records and CompuBox stats. Protocol 4; every review thread on #293 and #294 is fixed with a regression test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant