Unable to transform control code for a Top Down RPG player into React Hook

First please excuse me for this long post. I tried to make it the smaller I can but everything come together so I cannot make it smaller than that.

The need

I want to create a Top Down RPG online (like Pokemon), and I like React so I did choose it as Framework.
I tried one time, didn’t work.. So I tried another, and something like 6 different solution but impossible..

So I did a vanilla JS version of it

A working JS vanilla version

// ----- Const -----

const DIRECTION = {
  UP: 'up',
  DOWN: 'down',
  LEFT: 'left',
  RIGHT: 'right',
};

const FPS = 5;
const MOVE_TIME = 1000/FPS;   //
const MOVE_DISTANCE = 64/FPS; //  64px/sec

// ----- Variable -----

let isWalking = false;
let direction = DIRECTION.DOWN;
let position = {x:64, y:32};


let isDirectionPressed = {}; // [DIRECTION]: bool
let timerId = null;
let delayId = null;
let lastMoveTime = 0;

// ----- Refresh HTML -----

const updatePlayer = () => {
  player.style.top = `${position.y}px`;
  player.style.left = `${position.x}px`;
  player.className = `${direction} ${isWalking ? 'walk' : ''}`;
}

// --------------- Engine ---------------

// Init
updatePlayer();

// Start moving in a direction
const go = (dir) => {
  // If we already know that, avoid
  if (!dir || isDirectionPressed[dir] && isWalking) {
    return;
  }

  isDirectionPressed[dir] = true;
  direction = dir;
  isWalking = true;
  clearTimers();

  // Do we start now ? Or wait (because we already move/stop during the delay - Low FPS)
  let now = new Date().getTime();
  let delay = Math.max(0, (lastMoveTime + MOVE_TIME) - now);
  delayId = setTimeout(() => {
    if (isWalking) { // Are we still walking after delay ?
      move();
      timerId = setInterval(() => {
        move();
      }, MOVE_TIME);
    }
  }, delay);

  updatePlayer();
}

// Do the move here
const move = () => {
  lastMoveTime = new Date().getTime();

  if (direction == DIRECTION.UP) {
    position.y -= MOVE_DISTANCE;
  }
  if (direction == DIRECTION.DOWN) {
    position.y += MOVE_DISTANCE;
  }
  if (direction == DIRECTION.LEFT) {
    position.x -= MOVE_DISTANCE;
  }
  if (direction == DIRECTION.RIGHT) {
    position.x += MOVE_DISTANCE;
  }

  updatePlayer();
}

// Clear 
const clearTimers = () => {
  clearInterval(timerId);
  timerId = null;
  clearTimeout(delayId);
  delayId = null;
}


// --------------- Events ---------------

const onKeyDown = (e) => {
  go(keyCodeToDirection(e.keyCode));
};

const onKeyUp = (e) => {
  const dir = keyCodeToDirection(e.keyCode);
  if (!dir) {
    return;
  }
  isDirectionPressed[dir] = false;

  // Stop here (or go in another pressed direction)
  if (dir === direction) {
    if (isDirectionPressed[DIRECTION.UP]) direction = DIRECTION.UP;
    else if (isDirectionPressed[DIRECTION.DOWN]) direction = DIRECTION.DOWN;
    else if (isDirectionPressed[DIRECTION.LEFT]) direction = DIRECTION.LEFT;
    else if (isDirectionPressed[DIRECTION.RIGHT]) direction = DIRECTION.RIGHT;
    else {
      isWalking = false; // Stop
      clearTimers();
      updatePlayer();
    }
  }
};

window.addEventListener('keyup', onKeyUp);
window.addEventListener('keydown', onKeyDown);

const keyCodeToDirection = (keyCode) => {
  if (keyCode === 37) { return DIRECTION.LEFT }
  else if (keyCode === 38) { return DIRECTION.UP }
  else if (keyCode === 39) { return DIRECTION.RIGHT }
  else if (keyCode === 40) { return DIRECTION.DOWN }
}
html, body {
  height: 100%;
  margin: 0;
  padding: 0;
}
body {
  display: flex;
}

#viewport {
  width: 600px;
  height: 150px;
  margin: auto;
  border: 1px solid black;
  position: relative;
}

#player {
  position: absolute;
  background: grey;
  width: 32px;
  height: 32px;
}
#player.up {
  border-top: 3px solid black;
}
#player.down {
  border-bottom: 3px solid black;
}
#player.left {
  border-left: 3px solid black;
}
#player.right {
  border-right: 3px solid black;
}
#player.walk {
  background: orange;
}
    <div id="viewport">
      <div id="player" style="top:32px; left:32px;"></div>
    </div>

N-th times try with React

Now I’m sure I have a working version of this code, and I want to make a useControllable hook who can do the same and return {position, direction, isWalking} state.

Player Component

import React from 'react';

import Character from './character'; 
import useControllable from '../hooks/useControllable';

const Player = () => {
  const { position, direction, isWalking }  = useControllable();

  return <Character position={position} skin={1} direction={direction} isWalking={isWalking} />
};

export default Player;

useControllable Hook

import { useEffect, useState } from "react";

const DIRECTION = {
  UP: 'up',
  DOWN: 'down',
  LEFT: 'left',
  RIGHT: 'right',
};

const FPS = 20;
const MOVE_TIME = 1000/FPS;   //
const MOVE_DISTANCE = 64/FPS; //  64px/sec


export default function useControllable (_position={x:0,y:0}, _direction=DIRECTION.DOWN) {

  // ----- Define States -----

  let [position, setPosition] = useState(_position);
  let [direction, setDirection] = useState(_direction);
  let [isWalking, setWalking] = useState(false);

  // ----- Intern vars -----

  let isDirectionPressed = {}; // [DIRECTION]: bool
  let timerId = null;
  let delayId = null;
  let lastMoveTime = 0;

  // ----- Engine -----

  const go = (dir) => {
    // If we already know that, avoid

    if (!dir || isDirectionPressed[dir] && isWalking) {
      return;
    }

    isDirectionPressed[dir] = true;
    clearTimers();

    setDirection(dir); // direction = dir;
    setWalking(true); // isWalking=true;

    // Do we start now ? Or wait (because we already move/stop during the delay - Low FPS)
    let now = new Date().getTime();
    let delay = Math.max(0, (lastMoveTime + MOVE_TIME) - now);

    // console.log("Start Walking. Delay: " + delay);

    delayId = setTimeout(() => {
      if (isWalking) {
        move();
        timerId = setInterval(() => {
          console.log("Tic");
          move();
        }, MOVE_TIME);
      }
    }, delay);

  }

  const move = () => {

    lastMoveTime = new Date().getTime();

    if (direction === DIRECTION.UP) {
      setPosition({x: position.x, y: position.y - MOVE_DISTANCE});
      // position = {x: position.x, y: position.y - MOVE_DISTANCE};
    }
    else if (direction === DIRECTION.DOWN) {
      setPosition({x: position.x, y: position.y + MOVE_DISTANCE});
      // position = {x: position.x, y: position.y + MOVE_DISTANCE};
    }
    else if (direction === DIRECTION.LEFT) {
      setPosition({x: position.x - MOVE_DISTANCE, y: position.y});
      // position = {x: position.x - MOVE_DISTANCE, y: position.y};
    }
    else if (direction === DIRECTION.RIGHT) {
      setPosition({x: position.x + MOVE_DISTANCE, y: position.y});
      // position = {x: position.x + MOVE_DISTANCE, y: position.y};
    }

  }

  const clearTimers = () => {
    clearInterval(timerId);
    timerId = null;
    clearTimeout(delayId);
    delayId = null;
  }

  // ----- Event Management -----

  const onKeyDown = (e) => {
    go(keyCodeToDirection(e.keyCode));
  };

  const onKeyUp = (e) => {
    const dir = keyCodeToDirection(e.keyCode);
    if (!dir) {
      return;
    }
    isDirectionPressed[dir] = false;

    // Stop here
    if (dir === direction) {
      if (isDirectionPressed[DIRECTION.UP]) setDirection(DIRECTION.UP); // direction = DIRECTION.UP
      else if (isDirectionPressed[DIRECTION.DOWN]) setDirection(DIRECTION.DOWN); // direction = DIRECTION.DOWN
      else if (isDirectionPressed[DIRECTION.LEFT]) setDirection(DIRECTION.LEFT); // direction = DIRECTION.LEFT
      else if (isDirectionPressed[DIRECTION.RIGHT]) setDirection(DIRECTION.RIGHT); // direction = DIRECTION.RIGHT
      else {
        setWalking(false); // isWalking=false;
        clearTimers();
      }
    }
  };

  const keyCodeToDirection = (keyCode) => {
    if (keyCode === 37) { return DIRECTION.LEFT }
    else if (keyCode === 38) { return DIRECTION.UP }
    else if (keyCode === 39) { return DIRECTION.RIGHT }
    else if (keyCode === 40) { return DIRECTION.DOWN }
  }

  useEffect(() => {
    window.addEventListener('keyup', onKeyUp);
    window.addEventListener('keydown', onKeyDown);
    return () => {
      window.removeEventListener('keyup', onKeyUp);
      window.removeEventListener('keydown', onKeyDown);
    };
  }, []);

  return { position, direction, isWalking };
}

But I never works.. When I update a state (like setDirection) the variable direction isn’t updated into the hook, so function always have it as “down” (initial value).
So I tried to update them in the same time (see comment after setter) setDirection(dir); // direction = dir;

Also Timeout and Interval are f*cked here, they fire to much, or not at all.. I tried some version using useEffect() to simulate an Interval (see code under) but it’s always buggy (and the Tic function have to stay on all the time, I cannot stop it. And I’ll have NPC too who can also walk, don’t want one by Character)

  // Tic for animation...

  useEffect(() => {
    const id = setTimeout(() => {
      console.log("Tic " + playerDir, `[${playerPos.x};${playerPos.y}]`);
      setLastMoveTime(new Date().getTime()); // Make it recall later

    }, MOVING_TIME);

    return () => {
      clearTimeout(id);
    }
  }, [lastMoveTime]);

I probably miss something.. Thank all for taking time to read everything.