Repository navigation
Hands: realism overhaul (simulation feel, netcode, animation, injuries, presentation) - #293
Conversation
…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.
There was a problem hiding this comment.
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.
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>
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>
… 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>
…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>
… 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>
…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>
…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>
Follow-up: the review items that weren't tied to a lineAll 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 Engine (batch 4)
Rooms and server (batches 1 and 2)
Client tests (batch 2)
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:
|
…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>
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
Simulation feel and netcode
facing_x,facing_y,last_input_sequence); snapshots broadcast every tick; square ring with isotropic world mapping.Runtime pose system (
web/hands/src/render/rig.ts,poser.ts,graph.ts)?model-lab=1&pose=...&trauma=heavy&cam=faceis a pose lab used for screenshot review (pose=seated|celebrate|wave_offfor the new poses).Injuries, gore, lighting, camera (
web/hands/src/render/injury.ts,effects.ts,ring.ts)Feel and delivery
Verified end to end without Discord
scripts/hands_e2e_server.pyruns the production server with a development auth backend (real tickets, rooms, websocket protocol, engine and persistence); the client's?e2e=1switch (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.jscovers 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.authentication_timeout), and an input whoseclient_ticklagged the snapshot by more than the window was treated as a fatal protocol violation (invalid_input, forfeit). The renderer is now built onwelcome, 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 fatalmatch_completeclose, 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).
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=1runs 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
responsescenario: 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.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=1path 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.welcomeandresumedand 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).room_closedduring a rematch.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,latencyandresponseall 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:8080and 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:
uv run intelstream(one bot token must not run twice).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 intosrc/intelstream/hands/static/).curl -s http://127.0.0.1:8080/healthzand 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.uv run intelstreamfrom~/Development/intelstreamagain.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.