Surviving used to be its own reward, which is a nice way of saying the game gave you nothing for lasting. The clock was also working against you in two quiet ways: switch tabs and the run kept advancing without you, and resize the window and the game quietly simulated extra time. All three changed today. The headline: every 3 levels, your shield gets a permanent upgrade for the rest of the run, announced right on the HUD. It is live in the game below, no frozen build this time.
The live game, not a snapshot. Reach level 3 and watch the top of the screen: a reward announcement, and a new Rewards count under your score. Switch tabs mid-run and it waits for you, paused.
Click the game once so it captures your keyboard. Keyboard, mouse, gamepad, or touch drag all work.
The design notes always said levels should come with rewards. Now they do. Every third level grants one upgrade, cycling through three kinds: a bigger shield, a wider shield orbit, then a faster shield orbit. Each step is small, about ten percent, and they stack for the rest of the run. Nothing is written to storage, so a fresh run starts clean, same as it always did.
grantRewardThe grant fires from the level-up check, only while a run is actually live. The toast is drawn by the HUD menu and counts its own ticks down.
const REWARD_KINDS = [
{ key: 'size', label: 'Bigger shield' },
{ key: 'radius', label: 'Wider shield orbit' },
{ key: 'speed', label: 'Faster shield orbit' },
]
const grantReward = () => {
const kind = REWARD_KINDS[rewardState.total % REWARD_KINDS.length]
rewardState[kind.key]++
rewardState.total++
rewardToast = { text: `Reward: ${kind.label}`, ticksLeft: 240 }
}
Before this, hiding the tab changed nothing: the clock kept ticking, levels kept climbing, and the swarm kept coming, all with nobody playing. Now a hidden tab pauses the run exactly like pressing Escape. Start menus and the game over screen are untouched, and coming back never unpauses on its own.
visibilitychangeFour conditions, one assignment. Mid-run only, and only into pause, never out of it.
document.addEventListener('visibilitychange', () => {
if (document.hidden && !preGame && !isGameOver && !pause) {
pause = true
}
})
The old resize handler refit the canvas, then ran a full game update to redraw. Dragging a window edge fired that update dozens of times: free time, free spawns, even behind the pause menu. Positions in this game are normalized, so a refit only needs the new canvas size and fresh offsets. The new handler clamps anything mid-flight back inside the field bounds, draws one frame, and leaves the run exactly where it was. Phone rotation gets the same treatment.
handleResizeRegistered for both resize and orientationchange. No update call, so no simulated time sneaks in.
const handleResize = () => {
canvas.height = window.innerHeight
canvas.width = window.innerWidth
gameSize = getGameSize()
gameOffset = getGameOffset()
clampEntitiesToField()
draw()
}