Custom transfer using ERC20

Well, I need the following help.
I'm developing a game, and in this game my ERC20 coin will have by default 1billion of the token in a wallet called "Rewards".
That way, my contract for that currency when I implement it will automatically mine 1billion for the "Rewards" address, so far, ok.

At some point I need to withdraw coins from my wallet (rewards) and move to another wallet (players).

For this situation I found an alternative, which I don't believe is the safest, because it's about adding in the frontend of my game the option for web3 to access my wallet (rewards) through PrivateKey, but as I said, this transaction by if it were handled on the frontend, my privateKey would be exposed and anyone could steal my account.

That said, what did I do?
I created in my contract a method called "withdrawRewards", my idea is basically to do the "transfer" process that the ERC20 contract already has, the only difference that would not go through approve, and I would be validating based on a signature that I would be generating using the ECDSA lib, but what's the problem with that?
The problem is that the variable "_balances" is private and I can't access it, even though the contract is extending the ERC20.

My question is, how can I do this in the best and safest way possible?

    // SPDX-License-Identifier: MIT
    pragma solidity >=0.4.22 <0.9.0;
    
    import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
    import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
    import "@openzeppelin/contracts/security/Pausable.sol";
    import "@openzeppelin/contracts/access/AccessControl.sol";
    import "@openzeppelin/contracts/access/Ownable.sol";
    import "@openzeppelin/contracts/utils/math/SafeMath.sol";
    import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
    
    contract MyToken is ERC20, Pausable, AccessControl, ERC20Burnable, Ownable  {
        using SafeMath for uint256;
    
        bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
        bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
    
        mapping (address => uint256) private _nonces;
        address private sigAddres;
    
        //wallet 1kkk
        // address public rewardsAddr          = address(0x59D6fcF2e8210cDC481389c21f2D6); 
        address public rewardsAddr          = address(0x961aCa5d56EfD5971aF8F);     
        //Tax address
        address public taxAddr   = address(0x90475bB7492F3c5949475FDD1);
    
        //tax buy/sell initial 5%     
        uint256 private transferFees = 5;
        bool private initializedFees = true; 
    
        constructor() ERC20("TOKEN COIN", "TOKEN") {
            _setupRole(DEFAULT_ADMIN_ROLE, msg.sender);
            _setupRole(PAUSER_ROLE, msg.sender);
            // _mint(rewardsAddr, 1000000000 * 10 ** decimals());
            _mint(owner(), 1000000000 * 10 ** decimals());
            _setupRole(MINTER_ROLE, msg.sender);                
        }
    
        /**
        * @dev Fallback function if ether is sent to address 
        **/
        receive() external payable {
            payable(owner()).transfer(msg.value);
        }
    
        /**    
         * @dev overrides transfer for tokenomics
         */
        function _transfer(
            address sender,
            address recipient,
            uint256 amount
        ) internal virtual override{
            // require(tokenDenylist[msg.sender] == false, "Address in deny list");
            require(amount > 0, "ERC20: transfer amount must be greater than zero");
            require(sender != address(0), "ERC20: transfer from the zero address");
            require(recipient != address(0), "ERC20: transfer to the zero address");                
    
            if(hasFees(sender, recipient)){
               
               uint256 feesTotal = amount.mul(transferFees).div(100);
               amount = amount - feesTotal;
               
               //wallet transfer fees
               super._transfer(sender, taxAddr, feesTotal);
            }
    
           super._transfer(sender, recipient, amount);
        }
    
        function withdrawRewards(address _player, uint256 _amount, uint256 _nonce, bytes memory _signature)
        public virtual isValidSignature(_player, _amount, _nonce, _signature) returns(bool){
            
            uint256 rewardsBalance = _balances[rewardsAddr];
            require(rewardsBalance >= _amount, "ERC20: transfer amount exceeds balance");
            if(_nonces[_player] >= _nonce){
                revert("Withdraw already used");
            }
            unchecked {
                _balances[rewardsAddr] = rewardsBalance - _amount;
            }
            _balances[_player] += _amount;
    
            _nonces[_player] = _nonce;
    
            emit Transfer(rewardsAddr, _player, _amount);
    
            return true;
        }
        
        function hasFees(address sender, address recipient) private view returns(bool){
            if(initializedFees == true && 
                (transferFees > 0) 
                && (sender != taxAddr)  
                // && (sender != holdersAddr)              
                && (sender != owner()) 
                && (recipient != taxAddr)
                // && (recipient != holdersAddr)
                && (recipient != rewardsAddr)){
                    return true;
                }
                
                return false;
        }
    
        function pause() public onlyRole(PAUSER_ROLE) {
            _pause();
        }
    
        function unpause() public onlyRole(PAUSER_ROLE) {
            _unpause();
        }
    
        function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) {
            _mint(to, amount);        
        }
    
        function _beforeTokenTransfer(address from, address to, uint256 amount)
            internal
            whenNotPaused
            override
        {
            super._beforeTokenTransfer(from, to, amount);
        }
    
        modifier isValidSignature(address _player, uint256 _amount, uint256 _nonce, bytes memory _signature) {
            bytes32 message = keccak256(abi.encodePacked(_player, _amount, _nonce));
            assert(ECDSA.recover(message, _signature) == sigAddres);        
            _;
        }
    
        function setSig(address _sig) public onlyOwner{
            sigAddres = _sig;
        }
    
        function updateAddrFees(uint _fee) public onlyOwner{
            transferFees = _fee;
        }
    
        function initFees(bool _startFee) public onlyOwner{
            initializedFees = _startFee;
        }
    }

Hello friend
I have the same problem, maybe you found the solution? you help me please.
and you can also help me with the other solution you found first please.
Thank you in advance.

An easier way may be just deploying a smart contract as a wallet, i.e., Rewards wallet and players wallet are both smart contracts whose owner is your EOA wallet. Then you don't have to worry about leaking the private key. Any attempt to modify the underlying erc20 contract is not considered a good practice.

Thanks for your answer, do you have an example of this? I understand what you said but I couldn't see it in the code..

Thanks again for the reply.

There are many articles about smart contract wallets on-line and examples include Link1, Link2.

These are on the commercial level, and what I had in mind was really just a smart contract with basic functionalities such as withdrawing (because a smart contract cannot stop anyone from sending tokens to it.)

You can call your approve function from your frontend to the tokens smart contract through the token address using the IERC20 interface