High CPU usage when game is over
When the game is over, it uses ~14% CPU time on my machine, which means that 1 core is 100% busy. Adding a pygame.time.delay(60) into the while is_over loop helps.
Static members
Your classes seem to define the properties twice, once in __init__ and once as static members. I don't see any usage of the static members, so these can be removed in Snake:
head: List[int, int]
color: Tuple[int, int, int]
body: List[List[int, int]]
direction: str
size: int
and in Food:
x: int
y: int
color: Tuple[int, int, int]
state: bool
position: Tuple[int, int]
Potential stack overflow in game over mode
Once the game is over, there's a loop handling the situation:
while is_over:
I expected the game to get out of this loop when a new round begins. However, that's not the case. Instead there is
if event.key == pygame.K_r:
game()
This is a recursive call. It's unlikely that it will cause problems in this particular game, but in general, this may cause stack overflows.
It can be resolved by introducing another loop
while running:
while is_over and running:
...
# Initialization code here
while running and not is_over:
...
Instead of calling game(), you can then set is_over = False.
Unused variable / unreachable code
The while running loop can be replaced by a while True, since there's no other assignment to running which would terminate the loop.
This also means that the code after while running will never be reached:
pygame.quit()
quit()
Changing the exit routine to running = False, you save some duplicate code and the code runs to the end. This is e.g. helpful if you later want to implement saving a highscore list etc. If you have many exit points during your program, it will be harder to implement something at the end of the game.
You can also omit quit(), because it is not helpful as the last statement of your code.
Smaller improvements
food.update() is only called with False as a parameter. It's never called with True. So this argument can be omitted and go hard-coded into the update() method. The code then looks like this:
while running:
...
food_pos = food.spawn(board_width, board_height, block_size)
if collision(snake, *food_pos):
score += 1
food.update()
This reads like the food is spawning in a new place with every frame. IMHO it reads better like this:
while running:
...
food_pos = food.??? # whatever
if collision(snake, *food_pos):
score += 1
food.spawn(board_width, board_height, block_size)
Because that makes it clear that food only spaws whenever it collided with the snake aka. it was eaten.