Preventing Players from Cheesing

Media in this article may trigger epileptic seizures. Reader discretion is advised.

While watching people play my game, Plight of the Wizard, I observed a clever tactic. Some players learned how enemies spawn and used it to their advantage. The enemy spawner I implemented spawns enemies a certain distance away from the player. This way, if the player's back is to a corner, then the enemy won't spawn because it would be unfair for an enemy that is off-screen to kill the player. Naturally, a few players picked up on this and began camping in the corner to make the game easier.

The player camping in the corner to cheese the enemy spawn system.

This treachery must be punished! I think the best answer to the problem is to ask "What would Zeus do?" when the humans misbehave. The answer is resoundingly to smite them with lightning.

First attempt

My first attempt consisted of creating an idle timer that calls a function, smiteIdleEntityWithLightning, after the timer finishes.

-- player.lua
function Player:init(x, y)
    self.idleTimer = pd.timer.new(3000, function()
        if not gameOver then
            smiteIdleEntityWithLightning(self.x, self.y)
        end
    end)
    self.idleTimer.repeats = true
end

The idle timer resets whenever the player moves, so that they can't stay in place.

-- player.lua
function Player:update()
    if pd.buttonIsPressed(pd.kButtonUp) then
        self.idleTimer:reset()
    end

    if pd.buttonIsPressed(pd.kButtonDown) then
        self.idleTimer:reset()
    end

    if pd.buttonIsPressed(pd.kButtonRight) then
        self.idleTimer:reset()
    end

    if pd.buttonIsPressed(pd.kButtonLeft) then
        self.idleTimer:reset()
    end
end

Next, I created some Sparks that provide the player with a visual warning that they are about to be punished for their misdeeds.

-- sparks.lua
function Sparks:init(x, y)
    self:moveTo(x, y)
    self:add()
end

Finally, I implemented the smiteIdleEntityWithLightning function to cast Lightning upon the player after the sparks have cleared.

-- disastersSpawner.lua
function smiteIdleEntityWithLightning(x, y)
    local sparks = Sparks(x, y)

    pd.timer.performAfterDelay(3000, function()
        sparks:remove()
        Lightning(x, y)
    end)
end

We can see the result here:

The player runs away from the lightning as soon as they see sparks.

This is a great start, but I immediately see an issue with it. An experienced player will see the sparks and immediately run away from their position. The current implementation provides too much of a heads up.

Second attempt

In order to address the player running away, I decided to update Sparks to track the player's movement. I then updated smiteIdleEntityWithLightning to take self from the Player instead of only passing the player's x and y coordinates.

-- player.lua
function Player:init(x, y)
    self.idleTimer = pd.timer.new(3000, function()
        if not gameOver then
            smiteIdleEntityWithLightning(self)
        end
    end)
    self.idleTimer.repeats = true
end

Next, I updated Sparks to move the sprite's position to the player's current position in its update function when passed a playerInstance.

-- sparks.lua
local playerInstance = nil

function Sparks:update()
    if playerInstance then
        self:moveTo(playerInstance.x, playerInstance.y)
    end
end

function Sparks:setPlayerInstance(player)
    playerInstance = player
end

Finally, I called setPlayerInstance on the Sparks in the spawner function to pass it the player to track.

-- disastersSpawner.lua
function smiteIdleEntityWithLightning(entity)
    local sparks = Sparks(entity.x, entity.y)
    sparks:setPlayerInstance(entity)

    pd.timer.performAfterDelay(3000, function()
        local sparksFinalX, sparksFinalY = sparks:getPosition()
        sparks:remove()
        Lightning(sparksFinalX, sparksFinalY)
    end)
end

Let's take a look at the updated implementation:

The lightning strike now follows the player.

This solves the previous issue where the player could start running as soon as they saw sparks, but it doesn't provide them the ability to actually avoid the lightning. The goal is to keep the player moving, not to immediately end their game.

Third attempt

What if the sparks tracked the player for some time but then eventually settled on a position where the lightning would strike and the player could run away from?

This requries clearing Spark's player instance so that its update loop will stop moving the sprite.

-- sparks.lua
function Sparks:clearPlayerInstance()
    if playerInstance then
        playerInstance = nil
    end
end

Then in smiteIdleEntityWithLightning, I called clearPlayerInstance on Sparks after an amount of time had passed and then stored the sprite's position before clearing it. Using the stored position allowed me to pass Lightning a fixed position to strike that the player can move away from.

-- disastersSpawner.lua
function smiteIdleEntityWithLightning(entity)
    local sparks = Sparks(entity.x, entity.y)
    sparks:setPlayerInstance(entity)

    local sparksFinalX, sparksFinalY
    pd.timer.performAfterDelay(2000, function()
        sparks:clearPlayerInstance()
        sparksFinalX, sparksFinalY = sparks:getPosition()
    end)

    pd.timer.performAfterDelay(3000, function()
        sparks:remove()
        Lightning(sparksFinalX, sparksFinalY)
    end)
end

We can see the successful result here:

The player being tracked by the lightning but successfully dodging the strike.

The code is almost there now, but there is still a way the player can finagle their way out of the lightning strikes spawning altogether.

Fourth attempt

Recall the code from earlier that resets the smite timer:

-- player.lua
function Player:update()
    if pd.buttonIsPressed(pd.kButtonUp) then
        self.idleTimer:reset()
    end

    if pd.buttonIsPressed(pd.kButtonDown) then
        self.idleTimer:reset()
    end

    if pd.buttonIsPressed(pd.kButtonRight) then
        self.idleTimer:reset()
    end

    if pd.buttonIsPressed(pd.kButtonLeft) then
        self.idleTimer:reset()
    end
end

All the player has to do to avoid the lightning from spawning is input any movement. Even the most minor input will reset the timer, and I assume my players are both smart and devious, which is a dangerous combination.

The player continously moving to prevent the lightning from spawning.

So what can be done? In this case, I think that checking for a larger position on the screen will help solve the problem. Rather than checking for any movement, I'll instead check for a certain movement distance before resetting the smite timer.

To check for the distance traveled, I created a helper function:

-- helper function
function calculatePositionDelta(position1, position2)
    local deltaX = position1[1] - position2[1]
    local deltaY = position1[2] - position2[2]
    local positionDelta = math.sqrt(deltaX * deltaX + deltaY * deltaY)
    return positionDelta
end

I then updated the idle timer in the Player class to check if the player has moved further than a certain threshold.

-- player.lua
function Player:init(x, y)
    self.currentPositionX, self.currentPositionY = self:getPosition()
    self.previousPositionX = self.currentPositionX
    self.previousPositionY = self.currentPositionY

    self.idleTimer = pd.timer.new(3000, function()
        self.previousPositionX = self.currentPositionX
        self.previousPositionY = self.currentPositionY
        self.currentPositionX, self.currentPositionY = self:getPosition()

        local currentPosition = { self.currentPositionX, self.currentPositionY }
        local previousPosition = { self.previousPositionX, self.previousPositionY }
        local positionDelta = calculatePositionDelta(currentPosition, previousPosition)

        if positionDelta < 15 then
            smiteIdleEntityWithLightning(self)

        end
    end)
    self.idleTimer.repeats = true
end

Notice that I'm no longer calling self.idleTimer:reset(). Instead, the timer continuously loops and only checks for distance changes since its last run. This works much more effectively than my previous approach.

Finishing touches

By now you may be aware that the reason I can pick up on these devious players is because I am one myself. Using my experience exploiting flaws in games to my advantage, I can go on the defensive and anticipate what players will exploit next. My assumption is that players will start tracking the timer in their head. Worse, they will probably memorize the distance they can safely travel. Accordingly, I think fuzzing the numbers will help keep it a bit more unpredictable.

-- player.lua
function Player:init(x, y)
    self.currentPositionX, self.currentPositionY = self:getPosition()
    self.previousPositionX = self.currentPositionX
    self.previousPositionY = self.currentPositionY

    self.idleTimer = pd.timer.new(5000, function()
        self.previousPositionX = self.currentPositionX
        self.previousPositionY = self.currentPositionY
        self.currentPositionX, self.currentPositionY = self:getPosition()

        local currentPosition = { self.currentPositionX, self.currentPositionY }
        local previousPosition = { self.previousPositionX, self.previousPositionY }
        local positionDelta = calculatePositionDelta(currentPosition, previousPosition)

        -- add a fuzzing offset to the movementThreshold to make it more unpredictable
        local movementThreshold = 10 + math.random(0, 40)

        if positionDelta < movementThreshold then
            smiteIdleEntityWithLightning(self)

            -- add a fuzzing offset to the timer to make it more unpredictable
            self.idleTimer.duration = math.random(1000, 10000)
        end
    end)
    self.idleTimer.repeats = true
end

Now the lightning feels much more tricker to guess and also makes the gameplay feel a lot better! Before taking off my "exploiting player" hat, I'm going to make the assumption that these players are so desperate to gain a competitive advantage that they will read the code above to figure out the fuzzing. Nice try! I've changed all the numbers from my actual implementation. If you're going to implement something similar in your game, you'll have to toy around with different timings above to make it feel right.

Final result

The final lightning implementation showing lightning spawning even though the player is moving around a small amount.

Finally, we have a gameplay element that keeps the player on their toes and (hopefully) can't be circumnavigated with low-effort tactics. The player is forced to be alert and constantly moving. My goal of preventing player's from easily tricking the game rather than playing it is now closer to reality.