Repository navigation
Player Movement and Grappling
Superseded. This page records the Sprint 2 implementation and is kept only so older links still work. That version had right click held to stay attached, a rigid-rod rope and arrows that flew straight. The grapple now charges and arcs like the bow and uses a slack rope, so for current behaviour see Player Grappling. Walking, jumping and dash details are on Player Movement.
Reviewed 15 September 2026 against main 4941dba, which merged PR #145. The player mechanics described below are merged unless explicitly labelled as branch-specific.
The Grapple System lets the player fire a hook at terrain and swing from it as a pendulum. It reuses the projectile pipeline built for the bow, so the grapple is a physical arrow that travels and lands rather than an instant raycast.
Swinging is driven by Box2D physics rather than scripted motion, so momentum carries naturally into jumps and releases.
| Input | Action |
|---|---|
| 1–9 or comma/period | Select the inventory slot containing a rope arrow |
| Hold right click | Fire a rope arrow toward the cursor and remain attached |
| Release right click | Detach from the rope |
| Space (while attached) | Jump off the rope, keeping momentum |
| A / D (while attached) | Pump the swing |
| W / S (while attached) | Retract / extend the rope |
Rope arrows are not consumed. Q cycling is obsolete; slot numbers depend on inventory contents. E can dispatch rope-item use, but keyboard attachment still requires right-click held at impact, making right-click the normal grapple control.
flowchart LR
Input["Input / ItemUseComponent"] -->|grappleFire| Grapple["GrappleComponent"]
Grapple --> Factory["ProjectileFactory"]
Factory --> Arrow["GrappleArrowComponent"]
Arrow -->|queue attachment| Grapple
Grapple --> Joint["Box2D distance joint"]
Grapple --> Renderer["GrappleRenderComponent"]
Uses the selected inventory item to route shoot. A rope arrow triggers grappleFire immediately without consuming it; combat arrows use the charged-bow path. This replaces the former ArrowSelectionComponent/Q-selection path.
The input component doesn't know which arrow is selected, in the same way it doesn't know which weapon is equipped.
The grapple is aimed with the mouse, which means converting a screen pixel into a world-space direction. KeyboardPlayerInputComponent does this by unprojecting the click through the active camera:
Camera camera = cameraComponent.getCamera();
Vector3 worldPosition = camera.unproject(new Vector3(screenX, screenY, 0f));
return new Vector2(worldPosition.x, worldPosition.y).sub(entity.getCenterPosition());unproject() runs the camera transform backwards, accounting for camera position, zoom and viewport size, so the result stays correct as the camera moves or the window resizes. Subtracting the player's position turns that world point into a direction to fire along.
The camera is passed in rather than looked up globally. MainGameScreen hands renderer.getCamera() to the ForestGameArea constructor, which passes it to the input component in spawnPlayer():
KeyboardPlayerInputComponent input = newPlayer.getComponent(KeyboardPlayerInputComponent.class);
if (input != null) {
input.setCameraComponent(cameraComponent);
}If the camera is never set, aimed mouse input is ignored. Generic item use has its own rightward fallback direction; those two paths should not be confused.
Owns the grapple lifecycle. It listens for grappleFire, grappleRelease and grappleSwing, spawns the arrow, builds the joint, and applies swing force.
Rides on the fired arrow. It filters PhysicsLayer.SOLID (OBSTACLE | GROUND), excludes NPCs/boundary walls, requires at least 1.2 units of separation from the current player centre, and checks that right-click is still held when keyboard input is present. A valid collision latches once and calls:
grapple.attachTo(other.getBody(), entity.getCenterPosition());The arrow doesn't build the joint itself. Box2D locks its world during a physics step, so creating a joint inside a collision callback throws an assertion. The attachment is queued and built on the next frame instead.
Draws a brown rope while attached using a lazily created ShapeRenderer. Each segment returned by getRopePath() is drawn, including intervening bends, at width 0.05 world units.
-
fire()spawns a grapple arrow throughProjectileFactory.createGrappleArrow()and starts a two second cooldown - The arrow travels using the same
ArrowProjectileComponentas standard arrows, but with noCombatStatsComponentso it deals no damage - On terrain collision,
GrappleArrowComponentqueues the attachment -
GrappleComponent.update()builds a Box2DDistanceJointbetween the anchor body and the player - The player swings under gravity, with movement keys applying tangential force
- Releasing the button or jumping destroys the joint, preserving velocity
The cooldown is only set on a successful shot, so pressing the button while it's active doesn't extend the wait. fire() also returns early if already attached, so you can't stack ropes.
A DistanceJoint is used rather than a RopeJoint. A rope joint enforces a maximum length but allows slack, which produces a fall then jerk motion. A distance joint holds a fixed length, keeping the player on the arc so momentum carries to the other side like a real pendulum.
frequencyHz and dampingRatio are both zero, making the rope a rigid rod rather than a spring. collideConnected is true so the player still collides with the surface they grappled.
Swing force is applied perpendicular to the rope rather than horizontally, so it stays effective high in the arc:
Vector2 r = entity.getCenterPosition().sub(anchorPoint);
Vector2 tangent = direction > 0 ? new Vector2(-r.y, r.x).nor() : new Vector2(r.y, -r.x).nor();The joint pivots from the player's centre of mass (getLocalCenter()) so the pendulum hangs evenly. setFixedRotation(true) stops the player spinning on the rope.
While attached, linear damping is 0.5. The component saves the original damping once per grapple cycle and restores it when releasing; rebuilding joints around bends does not overwrite the saved value.
Pumping stops when total speed is greater than 7; this is not a hard velocity clamp. A zero direction applies no extra swing force.
While attached, PlayerActions redirects walking input into the swing rather than calling updateSpeed(). Without this, the impulse that snaps horizontal velocity to walking speed would cancel the pendulum every frame. The same guard applies in stopWalking(), sprint() and stopSprinting().
Air control is reduced to 10% of ground control so a fast landing from a swing isn't immediately wiped when a movement key is pressed.
Jumping while attached releases the rope and adds an upward impulse scaled by ROPE_JUMP_MULTIPLIER on top of existing velocity, so swinging fast and jumping at the bottom of the arc launches the player properly.
Terrain wrapping, persistent bends and W/S climbing/descent were merged in PR #145.
The rope keeps its original anchor and an ordered list of bend contacts from anchor toward player. Solid-geometry raycasts find obstructions; visible polygon vertices provide candidate bends, offset slightly outside the collider. Contacts retain body-local coordinates so their world points follow moving/rotating bodies.
A bend persists until its winding side changes and the shortcut between neighbouring points is unobstructed. When the player-side pivot changes, the distance joint is rebuilt at the last bend. The fixed anchor-to-bend path consumes part of the total rope length; the player joint receives the remainder. The renderer draws the resulting player-to-bends-to-anchor polyline.
The implementation limits the path to 16 contacts, supports polygon-vertex routing and uses a minimum joint length of 0.05. This is a constrained path with an active distance joint, not a rope made from many independently simulated links.
W emits grappleClimbStart/grappleClimbStop; S emits grappleDescendStart/grappleDescendStop. While attached, these retract or extend the requested path length at 3 world units/second. Holding both applies no adjustment.
Length is based on the actual current path each update, so blocked movement does not accumulate an ever-larger constraint request. Releasing a control synchronises the requested length back to the actual path. Extension is capped at the initial attachment length. Retraction normally leaves at least a one-unit player segment, subject to that initial-length cap; the joint also has its numerical minimum.
These controls change attached rope length rather than implementing free vertical walking. S also dispatches the separate updateLedgeDrop(true) request introduced by the level integration; see Player Movement.
| Constant | Class | Value | Purpose |
|---|---|---|---|
SWING_FORCE |
GrappleComponent |
7 | Tangent force multiplier, scaled by player mass |
MAX_SWING_SPEED |
GrappleComponent |
7 | Cap on pumped speed |
SWING_DAMPING |
GrappleComponent |
0.5 | How fast the swing decays |
| Release damping | GrappleComponent |
Saved pre-grapple value | Restore drag after letting go |
GRAPPLE_COOLDOWN |
GrappleComponent |
2s | Seconds between shots |
ROPE_JUMP_MULTIPLIER |
PlayerActions |
0.7 | Upward kick when jumping off |
AIR_CONTROL |
PlayerActions |
0.1 | Steering while airborne |
GRAPPLE_ARROW_SPEED |
ProjectileFactory |
22 | Straight-flight speed, zero gravity |
GRAPPLE_ARROW_RANGE |
ProjectileFactory |
50 | Distance from live shooter position |
Automated tests cover:
- Inventory-selected arrow routing through
ItemUseComponent - Rope-arrow non-consumption
- Conditional release, so only the grapple responds to the button being let go
- Aim direction passing through unchanged
- Mouse to world aiming, and safe handling when no camera is set
- Layer filtering, so the arrow only latches onto terrain
- Single attachment per arrow
- Safe handling when the shooter has no grapple component
- Guard clauses when release or swing are called while detached
- Headless construction of the render component
Relevant test classes:
ItemUseComponentTestGrappleArrowComponentTestGrappleComponentTestGrappleRenderComponentTestKeyboardPlayerInputComponentTest
The merged tests include real Box2D damping-restoration coverage. GrappleComponentGeometryTest covers edge discovery, rotated fixtures, moving obstacles, winding-side state and fixed-path length. Component/input tests cover shortening, extension limits, blocked length changes, stopping controls, W/S events and simultaneous controls. These tests were included in the 503-test pass at c900c8d; that count is revision-bound, not a fresh run of merged main.
PlayerMovementRegressionTest additionally checks that player actions reject dash while attached, release on Space with an upward boost while preserving horizontal momentum, and release when right-click is let go. These use a mocked grapple boundary; they do not simulate the complete rope path. PlayerLedgeDropIntegrationTest separately covers the S-key ledge route. See Player Movement.
Manually select a rope arrow, jump and hold right-click toward terrain. Check attachment, swing pumping, release and Space boost. Also route around a corner, reverse direction to unwrap, climb with W, descend with S, hold both, and test a moving platform. These new wrapping/climbing scenarios remain unverified here. Earlier user-reported airborne grapple success applies to branch 70545fc, before these additions.
There is no impact effect when the arrow lands. It disappears and the rope appears in the same frame.
The grapple can attach to any terrain on the OBSTACLE or GROUND layers, so there is currently nothing stopping a player grappling past sections of a level. Restricting valid anchor points is future work and needs coordination with the map team.
The renderer draws taut segments around bends, without physical sag.
The attachment and groundedness paths already use the equivalent SOLID mask; this is not an outstanding mismatch. Platform-side grapple permissions are not enforced by this collision path. Polygon-only routing and the contact limit constrain the wrapping implementation; arbitrary terrain shapes and all movement combinations are not proven by the included tests.
PR #145 playtesting reported the rope staying attached after an out-of-map checkpoint respawn: jump off the map, grapple its underside, then fall below the respawn threshold. The report also notes rope/terrain visual alignment and ignored grapple-side restrictions. These were not independently reproduced in this documentation update and are not claimed fixed by the new tests. The source respawn path repositions the existing player without explicitly releasing its grapple.
The grapple system, swing physics, rope rendering, camera aiming, arrow switching and associated tests were implemented by Darcy Franklin (DarcyMF). The projectile pipeline the grapple arrow reuses was built by Dhiren Malik (deerainn) as part of the ranged attack system.
That original credit includes historical arrow cycling; current selection integrates the inventory system. Later work includes Rohan Shirkhedkar's hold/SOLID-layer integration and new terrain wrapping, bend persistence and W/S climbing; Tristan Sheedy's damping restoration; and Jared Smith's moving-platform integration. New grapple geometry and climb tests accompany Rohan's merged commits.
Dhiren Malik added the player-action/grapple interaction regressions and merged-input/ledge coverage; these complement the grapple component and geometry tests without reassigning their authorship.
Entities and Components
Input Handling
Arrow Selection Wheel Arrow Selection Wheel — Sprint 3