Spielwerk 06: Olympische Sprünge

2026-11-01
Spielwerk 06: Olympische Sprünge - Teaser

Long jump from the side: speed comes from tapping, the angle from holding, and holding too long is a foul. It is the first game in the series where something flies, so the toolbox gets a small physics module: Kinematics, motion under constant acceleration, once as a frame step and once as a closed formula.

Play it · Source: collection-spielwerk/06-olympische-spruenge · Toolbox 5.5.0 or later

The game

Tap as fast as you can to run up; every tap adds speed and the track takes it away again. At the take-off board the speed freezes and the athlete starts leaning forward: press and hold, and the take-off angle sweeps up and back down, then release to jump. Hold until the toe crosses the line and it is "Übertreten!" (foul), shown as "ungültig" (invalid). Each attempt has its own wind, the distance is measured from the line to where the feet land, and after three attempts the best one counts ("Runde vorbei", round over). "Neue Runde" (new round) starts the next seed.

New in the toolbox: Kinematics

A body has a position and a velocity. Velocity is how the position changes per second, acceleration is how the velocity changes per second. Gravity is an acceleration that never changes, 9.81 m/s² downwards, and on a screen where y grows downwards that is a positive number, while "up" is a negative y velocity. That is all the physics a long jump needs. The question is how to compute it in a loop that hands me a delta_seconds every few milliseconds, and there are two answers.

The step. Each frame, acceleration times delta goes onto the velocity, then velocity times delta onto the position:

export function integrate(state: KinematicState, acceleration: Vector2I, delta_seconds: number): KinematicState {
    state.velocity.x += acceleration.x * delta_seconds;
    state.velocity.y += acceleration.y * delta_seconds;
    state.position.x += state.velocity.x * delta_seconds;   // with the NEW velocity
    state.position.y += state.velocity.y * delta_seconds;
    return state;
}

The order is deliberate. Velocity first, then position with the already updated velocity: that is the semi-implicit Euler method, and unlike "position first" it never gains energy from nothing. A ball bouncing under the other order bounces a little higher every frame. The step is still an approximation: between two frames it pretends the velocity is constant, and at 1/30 s per frame the athlete lands a few centimetres off. So the game flies in sub-steps of at most 1/240 s; the error shrinks with the step.

The formula. For constant acceleration the answer is exact: position is start plus velocity times time plus half the acceleration times time squared. That is projectile_position, and the rest follows from it. flight_time solves for the moment the height reaches the ground again (from the ground with an upward speed vy: 2·vy/g), range is horizontal speed times that time, apex is where the vertical velocity is zero, and launch_velocity turns a speed and an angle into a vector with "up" as negative y.

export function projectile_position(origin: Vector2I, velocity: Vector2I, gravity: number, t: number): Vector2 {
    return new Vector2(origin.x + velocity.x * t, origin.y + velocity.y * t + 0.5 * gravity * t * t);
}

export function flight_time(origin: Vector2I, velocity: Vector2I, gravity: number, ground_y: number): number {
    const height = ground_y - origin.y;          // positive: the ground is below the origin
    // (a straight-line case for gravity <= 0 is trimmed here)
    const discriminant = velocity.y * velocity.y + 2 * gravity * height;
    if (discriminant < 0) {
        return 0;
    }
    const t = (-velocity.y + Math.sqrt(discriminant)) / gravity;   // the larger root: the landing
    return t > 0 ? t : 0;
}

Why both? They answer different questions. The formula knows only constant acceleration: no ground, no wall, no wind that changes mid-flight. The step handles all of that but cannot look ahead; "where will I land?" needs the formula. The game uses exactly that split. The flight is stepped; that is the animation. The trail behind the athlete is drawn with projectile_position from the take-off. And the landing is both: before every sub-step the game asks flight_time whether the ground comes within this step, and if so puts the athlete exactly there instead of a frame late in the sand.

// src/game/Game.ts, fly(), trimmed
const step = Math.min(WORLD.flight_substep, remaining);   // at most 1/240 s
const to_ground = flight_time(this.position, this.velocity, WORLD.gravity, 0);
if (this.flight_seconds > 0 && to_ground <= step) {
    const landing = projectile_position(this.position, this.velocity, WORLD.gravity, to_ground);
    this.position.set(landing.x, 0);
    this.landing_x = landing.x;          // measured from the foul line
    return true;
}
integrate(this, { x: 0, y: WORLD.gravity }, step);

The take-off is two lines: launch_velocity(game.speed * WORLD.jump_factor, angle_in_radians), plus a quarter of the wind on x.

The tests in src/physics/kinematics/Kinematics.test.ts check the two ways against each other: integrate converges to projectile_position as the steps get smaller, flight_time from the ground is 2·vy/g, and the range behaves like the textbook says:

const at = (degrees: number) => range(ORIGIN, launch_velocity(20, degrees * Math.PI / 180), GRAVITY, 0);
expect(at(45)).toBeGreaterThan(at(30));
expect(at(30)).toBeCloseTo(at(60));
expect(at(45)).toBeCloseTo((20 * 20) / GRAVITY);      // v²·sin(90°)/g

The module knows no DOM and no clock; time comes in as an argument. Path of the module: src/physics/kinematics/Kinematics.ts.

From earlier steps

The scaffold, start screen, intro and scaling are explained once in Spielwerk 00: The workbench. Position and velocity are toolbox Vector2s; "Neue Runde" is a Button on a UiPage. What this step first wrote by hand for the dimmed panel over the scene is part of the canvas game shell now; the workbench lists it under "Extracted along the way".

Embed it

<script src="https://www.game-object.de/tce-elements/tobidot/dll-tce-assets"></script>
<script src="https://www.game-object.de/tce-elements/tobidot/tce-olympische-spruenge"></script>
<tce-olympische-spruenge init-seed="4711"></tce-olympische-spruenge>
Attribute Values Default
init-seed whole number; the first round, every new round is seed + 1 random
init-intro true / false; play the GO logo first true

Next

Previous: Spielwerk 05: Lock Picker · Next: Spielwerk 07: Dance Combo Trickster · All steps

Comments

captcha