Jump height and Gravity not consistent with fluctuating FPS (flappybird-like Javascript minigame)

When playtesting (Flappigtrail, v0.1.2), I noticed that the jump height works for 144 FPS but not so much for 60 FPS (the jump height is way lower for 60 FPS).

When the player jumps, player_dy becomes -2.7 (jump_height “is a constant” == 2.7); meaning that the playing will gain -2.7 (+ cumulative gravity ) per frame.
However, note that since gravity is 0.06, it will take:
| jump_height | * gravity == 2.7 / 0.06 == 45 frames at 144 fps to turn player_dy into 0, then negative with additional frames (add gravity; falling instead of flying up).
Note that 45 Frames / 144 FPS == 0.31 seconds
We can roughly model (maybe I’m wrong here ?) the height gained with nb_frames / 2 * jump_height == 22.5 * -2.7 == -60.75 => 61 pixels gained upwards for the jump at 144 FPS.

However, at 60 FPS, if gravity gained per height remains the same, it will still take 45 frames to get player_dy to 0, then negative with additional frames.
Note that 45 Frames / 60 FPS == 0.75 seconds (so more than twice as long).
Also note that while the jump height is the same as before (i.e. 61 pixels), it will take more than twice as long to get to player_dy == 0, giving this “jumping on the moon” feel (it is not desired).

Therefore, I thought of using the ratio BASE FPS (144) / CUR_FPS (e.g. 60 FPS) to update the amount of height the player should get from jumping && the amount of height the player should lose when get gravity applied each frame.

Note the scenario where we updated height gained (player_dy) from jump from -2.7 to -2.7 * (BASE_FPS / CUR_FPS) == -2.7 * 144/60 ~= -2.7 * 2.4 ~= -6.48
With this greater jump height and gravity unchanged, it will take:
| jump_height | * gravity == 6.48 / 0.06 == 108 frames before turning player_dy back to 0
=> i.e. nb_frames / 2 * jump_height == 108 / 2 * -6.48 == 54 * -6.48 ~= 350 pixels gained from the jump at 60 FPS with gravity unchanged
It will take us roughly 10 frames to gain 61 pixels (with gravity remained unchanged; yes, I have not accounted to player_dy losses from gravity_dy but it’s minimal here):
height_to_gain_px / | player_dy (after jump) | == 61 / 6.48 ~= 10 frames
Note that 10 Frames / 60 FPS ~= 0.17 seconds

However, we would also update the gravity gained per tick from 0.06 to 0.06 * (BASE_FPS / CUR_FPS) == 0.06 * 144/60
~= 0.06 * 2.4 == 0.144

With this greater gravity it will take:
| jump_height | * gravity == 6.48 / 0.144 == 45 frames before turning player_dy back to 0
=> i.e. nb_frames / 2 * jump_height == 45 / 2 * -6.48 == 22.5 * -6.48 ~= 146 pixels gained from the jump at 60 FPS with gravity unchanged
It will take us roughly 10 frames to gain 61 pixels (with gravity remained unchanged; yes, I have not accounted to player_dy losses from gravity_dy but it’s minimal here):
height_to_gain_px / | player_dy (after jump) | == 61 / 6.48 ~= 10 frames
Note that 10 Frames / 60 FPS ~= 0.17 seconds

Nonetheless, it seems that the 60 FPS jump in practice is still way higher than that…

Any clue ?

Edit:

If we only update gravity:
gravity * (BASE_FPS / CUR_FPS) == 0.06 * 2.4 == 0.144
| jump_height | * gravity == 2.7 / 0.144 == 18.75 Frames at 60 FPS to turn player_dy into 0.
Note that 18.75 Frames / 60 FPS == 0.31 seconds

This should work lol

Except–plot twist–it doesn’t

help…

See relevant code below

const BASE_FPS = 144;
const BASE_GRAVITY = IS_MOBILE ? 0.035 : 0.06;  // height lost per tick
const BASE_JUMP_Y = IS_MOBILE ? -1.3 : -2.7;  // player height gained per jump
// GAME LOGIC (per tick; e.g. 60 FPS)
function update(timeStamp) {
    requestAnimationFrame(update);  // request next frame

    calcDeltaTime(timeStamp);

    if (!gameStarted) {
        return;  // skip game logic updates if game did not start yet
    }
    // On player loss, display gameOver and retry texts
    if (gameOver) {
        spawn_bonus = true;  // reset in case player died before last_digit % 10 == 0
        drawGameOverText();  // draw gameOver text in center of screen
        if (!wroteRetry) {  // write retry only once
            waitAndDrawRetryText();  // draw retry text after GAMEOVER_TIME ms
            wroteRetry = true;
        }
        return;  // do not update canvas anymore if player lost
    }
    // // Skip current frame (if IRLplayer's PC framerate > FRAMERATE) or clear board/canvas
    // if (skipFrameOrClearBoard(timeStamp)) {  // if we should skip current frame
    //     return;  // skip current frame
    // }
    context.clearRect(0, 0, board.width, board.height);
    applyGravityAndBounds();  // constant heigth loss + bounds + gameOver on fall
    drawPipes();  // draw pipes every INTERVAL seconds (e.g. every 1s)
    drawScore();  // draw score text
    drawFPS();
}
/* Function: calcDeltaTime()
 * -----------------------------------------------------------------------------------
 * SUMMARY:
 *   Calculates deltaTime (for each update)
 */
function calcDeltaTime(timeStamp) {
    deltaTime = (timeStamp - lastFrameTime) / 1000;  // div 1000 for ms -> s
    lastFrameTime = timeStamp;  // 1 FRAME time length passed, update lastFrame2curFrame
}
/* Function: applyGravityAndBounds()
 * -----------------------------------------------------------------------------------
 * SUMMARY:
 *   Draw score at canvas top-middle
 */
function applyGravityAndBounds() {
    // player_dy += gravity * deltaTime * BASE_FPS;  // player is subject to GRAVITY
    player_dy += gravity * (BASE_FPS / (1 / deltaTime));  // applyGrav
    if (gamemode === GAMEMODE.EXTREME) {
        player_dy += gravity * (BASE_FPS / (1 / deltaTime));  // double gravity if EXTREME
    }
    if (player.y + player_dy < 0) {  // if player jumps above ceiling
        player.y = 0;  // block player on ceiling
        // Else if player fell (and is offscreen)
    } else if ((player.y + player_dy) > BOARD_HEIGHT) {
        gameOver = true;
    } else {  // player is in screen (normal case)
        player.y += player_dy;
    }
    player.y = Math.max(player.y + player_dy, 0);  // Math.max for ceiling
    if (player.img) {  // wait for player sprite to load
        context.drawImage(player.img, player.x, player.y, player.width, player.height);
    }
}
/* Function: playerJump()
 * -----------------------------------------------------------------------------------
 * SUMMARY:
 *   If IRLplayer presses Spacebar, ArrowUp or touches the screen (mobile), 
 *   makes character jump by player.jump_height
 */
function playerJump(event) {
    if (event.code == "Space" || event.code == "ArrowUp" || event.type === "touchstart"
        || event.type == "click") {
        if (!gameStarted) {
            gameStarted = true;
            // Start game logic
            spawnPipes();  // spawn first pipe without delay
            setInterval(spawnPipes, pipeSpawnInterval);  // spawn pipes pair from right
        }

        // player_dy = player.jump_height * (BASE_FPS / (1 / deltaTime));
        player_dy = player.jump_height;
        console.log("player_dy=", player_dy);
        console.log("player.jump_height=", player.jump_height);
        console.log("deltaTime=", deltaTime);
        console.log("FPS=", 1 / deltaTime);
        console.log("player.jump_height * deltaTime=", player.jump_height * deltaTime);

        applyFlap();

        // Reset the game if gameOver == true
        if (gameOver && restarting) {  // can only replay after restarting timer
            resetGlobals();// Reset game fields to defaults
        }
    }
}