(This is a post I can link to in Discord the next time somebody asks the frequently asked questions “Why is pyGame slow?“ or “Why do you even ise pyGame? It’s slow!”)
There’s some confusion about what is fast or slow in pyGame. With the new, SDL2-based version of pyGame around the corner, some programmers are excited about a new pyGame that could be fast.
I talked about pyGame in an IRC channel for Python programmers. “pyGame is slow“ said somebody “and I hope they take out all the software-based rendering and replace it with fast hardware rendering“.
But what exactly is the thing that makes the software rendering with SDL-1.2 “slow“? Is OpenGL fast? Will the new SDL2 with SDL_Renderer be fast?
Let’s look at some cases:
Software rendering scales with the number of pixels
Blitting a 16x16 surface onto another surface is fast. Blitting a 16x16 surface means copying 16*16=256 pixels, which are probably 256 machine words, onto another surface. If you blit two 16x16 surfaces, it will probably take twice as long.
Blitting a 64x64 surface will roughly take 16 times as long.
Rotating or scaling a small surface is also fast. Rotating a 1024x1024 surface is slow, because it’s a lot of pixels. Scaling a small surface up to 1920x1080 is slow.
Blitting 100 16x16 surfaces is probably faster than rotating a single 256x256 surface.
Whether you can blit, scale, or rotate once per frame or multiple times in a tight inner loop depends on the size of your surfaces.
I/O is always too slow
Loading an image from the hard disk with pygame.image.load is slow. You should load your images once and then keep them in ram.
Reading a text file from the hard disk is also potentially slow.
Writing a file to the hard disk is potentially slow.
Network i/o can lag sometimes, and be fast sometimes.
Don’t load and save things every frame! Either do this behind a loading screen, or in a separate thread!
OpenGL has high overhead (but then it gets fast)
Drawing a single 16x16 sprite as a textured quad can be slower than blitting in software.
Drawing 256 sprites with the same texture at different places on the screen is not much slower. The draw call has high overhead, but the marginal cost of another quad is low. It’s much faster than doing the same in software.
If you put sprites together into a texture atlas, you can draw different sprites in the same draw call, sharing the overhead.
Copying a texture to the GPU is slow. Copying a texture from RAM to the graphics card is definitely slower than blitting.
Copying a small texture to the GPU and scaling it up with a shader might still be faster than scaling up a surface in software.
Scaling up a surface in software and only then copying the big texture to the GPU is pointlessly slow.
If you are only drawing a bunch of quads, you can afford to update the coordinates each frame, rather than keeping the mesh on the GPU. It’s only 8 single-precision per quad, 4 x-y pairs of floats, so 32 bytes in total.
You can probably copy a texture it once per frame, or update certain textures when gameplay events happen. Ideally, you should load all your textures and meshes once only. That means you should not use functions like pygame.draw.line to draw into your textures every every frame.
Some things are performance traps
Iterating over the pixels in a surface with for-loops is slow. PyGame and numPy are optimised. Fragment shaders run in parallel. Don’t iterate over the pixels in for loops.
You can draw on surfaces, then blit these surfaces onto other surfaces, and then compose them together. It’s faster to blit everything to the screen in the right order. If you have multiple transparent layer surfaces, and blit each layer to the screen, then that touches a lot of pixels in every frame.
Font rendering is fast enough to do it every frame, but often you can just cache the resulting surface, especially if there is a lot of UI on your screen.
You can implement collision detection inefficiently. OpenGL won’t help you with collision detection.
You need to carefully profile if your algorithms are slow, or whether you can benefit from using a faster implementation. Maybe you can just take a slow operation out of a tight inner loop.
Game Engines are structured for performance
Game engines like Flixel, Unity3D, or Godot usually don’t blit things every frame, but they use long-lived objects for UI elements, sprites or 3D models. That means when a new game object is created, the graphics data is copied to the graphics card, and the fonts are rendered once. Until the game object is destroyed, only small changes need to be made.
For things that are instantiated and destroyed frequently, modern game engines have special systems, like particle systems and the FlxBullet system in HaxeFlixel that allows you to render and create many bullets at once.
Particle systems are fast because they render dozens or hundreds of instances of the same particle with the same shader in one OpenGL draw call.
But if you are drawing small clouds or small particles in 2D and in software with pyGame, you do not need hundreds of particles. Maybe you only need to blit a small surface a couple of times, which should be fast enough.
PyGame allows you to write your own main loop, and to control how and when to draw everything, and lets you mess with the pixels on the surfaces before and after you blit them.
TL;DR: PyGame might be slow, but not for the reasons you might think. Hardware acceleration is not a magic wand that makes everything fast. Some operations are slower with OpenGL hardware, even if the resulting game will be faster overall.
In the last part of this tutorial sequence, we will implement twitch.tv integration in a pygame game. If you have previously thought that all this async stuff was a bit too elaborate for too little gain, then you might be pleasantly surpised by a real-world example of two-way online interaction. This is not exactly netcode for a fighting game, or a MMORPG, or a lock-step simulation you can use in an RTS game or a MOBA, but it is what asyncio is good for.
To run the examples, you need pygame, python 3.6, asyncio, and the irc module (https://pypi.org/project/irc/). Install it with python3 -m pip install irc
Some of the code in here will be twitch-specific, but twitch chat is based on good old IRC, and you can take out these bits and add chat interaction with any IRC network if you want to. Also, I won’t explain to you how to stream your gameplay to twitch, but I used OBS to test out my examples and they work.
import pygame
import asyncio
import irc, irc.client_aio
import sys
if len(sys.argv)!=3:
print("please get a twitch auth token from https://twitchapps.com/tmi/")
sys.exit(1)
username=sys.argv[1]
token=sys.argv[2]
irc_pw=token.strip()
irc_user=username.strip()
assert(irc_pw.startswith("oauth:"))
To start the game, you pass your twitch username to the game, like so python3 game.py myusername oauth:tokenstringfromtwitch
You can get the IRC password from this oauth token generator here. If you are using another IRC network, you might want to change the code here already.
Let’s continue with the code. In the next part, we set up the IRC connecting and message handling
def parse_tags(event):
"""helper function to parse tags and twitch-specific user name field"""
if hasattr(event,"tags_dict"):
return event.tags_dict
tags={}
for tag in event.tags:
tags[tag["key"]]=tag["value"]
event.tags_dict=tags
try:
event.display_name=tags["display-name"]
except:
pass
class AsyncIRC(object):
"""Wrapper for irc functionality, with chat command decorators"""
def __init__(self, loop, prefix=">"):
self.loop = loop or asyncio.get_event_loop()
self.reactor=irc.client_aio.AioReactor(loop=self.loop)
self.connection=self.reactor.server()
self.commands=dict()
self.prefix=prefix
self.msgs=[]
def dispatch_cmd(connection, event):
"""message handler for chat commands"""
content=event.arguments[0]
if content.startswith(self.prefix):
sans_prefix=content[len(self.prefix):]
try:
cmd, *args = sans_prefix.split()
except:
cmd = sans_prefix
args=[]
cmd=cmd.lower()
if cmd in self.commands:
parse_tags(event)
return self.commands[cmd](connection, event, *args)
else:
print('invalid command: ' + cmd)
self.reactor.add_global_handler("pubmsg", dispatch_cmd, -1)
def handle(self, event_type, *args, **kwargs):
"""decorator for callbacks to handle generic IRC events"""
def decorator(fun):
self.reactor.add_global_handler(event_type, fun, *args, **kwargs)
return fun
return decorator
def broadcast(self, message):
self.connection.privmsg(self.channel, message)
def command(self, command):
"""decorator for callbacks to handle chat >commands"""
def decorator(fun):
self.commands[command]=fun
return fun
return decorator
def connect(self, server, nickname, join_channel,
password=None, port=6667):
def on_connect(connection, event):
connection.cap('REQ', ':twitch.tv/membership')
connection.cap('REQ', ':twitch.tv/tags')
connection.cap('REQ', ':twitch.tv/commands')
connection.join(join_channel)
if join_channel:
self.channel=join_channel
self.reactor.add_global_handler("welcome", on_connect)
self.channel=join_channel
coro=self.connection.connect(server=server, port=port, nickname=nickname, password=password)
self.loop.run_until_complete(coro)
We create an IRC client now, and add callbacks for chat events:
The first five handlers translate chat commands into pygame events, while the last one takes every chat message and logs the logs the content into a list. This way we can display chat history in the game.
and when we call run_once(loop), then the whole async IRC machinery will start looking for messages, handle them if there are any, and return.
Here comes the actual game part, based on wormy:
# Wormy (a Nibbles clone)
# By Al Sweigart al@inventwithpython.com
# http://inventwithpython.com/pygame
# Released under a "Simplified BSD" license
# some IRC features added by Robert Pfeiffer
import random, pygame, sys
from pygame.locals import *
FPS = 15
WINDOWWIDTH = 640
WINDOWHEIGHT = 480
CELLSIZE = 20
assert WINDOWWIDTH % CELLSIZE == 0, "Window width must be a multiple of cell size."
assert WINDOWHEIGHT % CELLSIZE == 0, "Window height must be a multiple of cell size."
CELLWIDTH = int(WINDOWWIDTH / CELLSIZE)
CELLHEIGHT = int(WINDOWHEIGHT / CELLSIZE)
# R G B
WHITE = (255, 255, 255)
BLACK = ( 0, 0, 0)
RED = (255, 0, 0)
GREEN = ( 0, 255, 0)
DARKGREEN = ( 0, 155, 0)
DARKGRAY = ( 40, 40, 40)
BGCOLOR = BLACK
UP = 'up'
DOWN = 'down'
LEFT = 'left'
RIGHT = 'right'
HEAD = 0 # syntactic sugar: index of the worm's head
def main():
global FPSCLOCK, DISPLAYSURF, BASICFONT, CHATFONT
pygame.init()
FPSCLOCK = pygame.time.Clock()
DISPLAYSURF = pygame.display.set_mode((WINDOWWIDTH, WINDOWHEIGHT))
BASICFONT = pygame.font.Font('freesansbold.ttf', 18)
CHATFONT = pygame.font.Font('freesansbold.ttf', 8)
pygame.display.set_caption('Wormy')
showStartScreen()
while True:
runGame()
showGameOverScreen()
Here, CHATFONT was added to the original wormy game to display chat in a different font.
In the game loop, we added chat messages when a new game is started, and we now call run_once(loop) before clock.tick(), like in the previous examples. To make the game playable with streaming lag (chat will usually be way ahead of the stream), the variable keydown_this_frame will ensure that the game state is only advanced when either a key has been pressed, or a keypress has been sent over a chat command.
def runGame():
bot.broadcast("new game")
bot.broadcast(">UP >DOWN >LEFT >RIGHT to play")
# Set a random start point.
startx = random.randint(5, CELLWIDTH - 6)
starty = random.randint(5, CELLHEIGHT - 6)
wormCoords = [{'x': startx, 'y': starty},
{'x': startx - 1, 'y': starty},
{'x': startx - 2, 'y': starty}]
direction = RIGHT
# Start the apple in a random place.
apple = getRandomLocation()
while True: # main game loop
keydown_this_frame=False
for event in pygame.event.get(): # event handling loop
if event.type == QUIT:
terminate()
elif event.type == KEYDOWN:
keydown_this_frame=True
if (event.key == K_LEFT or event.key == K_a) and direction != RIGHT:
direction = LEFT
elif (event.key == K_RIGHT or event.key == K_d) and direction != LEFT:
direction = RIGHT
elif (event.key == K_UP or event.key == K_w) and direction != DOWN:
direction = UP
elif (event.key == K_DOWN or event.key == K_s) and direction != UP:
direction = DOWN
elif event.key == K_ESCAPE:
terminate()
if keydown_this_frame:
# BEGIN MOVEMENT CODE
# check if the worm has hit itself or the edge
if wormCoords[HEAD]['x'] == -1 or wormCoords[HEAD]['x'] == CELLWIDTH or wormCoords[HEAD]['y'] == -1 or wormCoords[HEAD]['y'] == CELLHEIGHT:
return # game over
for wormBody in wormCoords[1:]:
if wormBody['x'] == wormCoords[HEAD]['x'] and wormBody['y'] == wormCoords[HEAD]['y']:
return # game over
# check if worm has eaten an apply
if wormCoords[HEAD]['x'] == apple['x'] and wormCoords[HEAD]['y'] == apple['y']:
# don't remove worm's tail segment
apple = getRandomLocation() # set a new apple somewhere
bot.broadcast("apple eaten")
else:
del wormCoords[-1] # remove worm's tail segment
# move the worm by adding a segment in the direction it is moving
if direction == UP:
newHead = {'x': wormCoords[HEAD]['x'], 'y': wormCoords[HEAD]['y'] - 1}
elif direction == DOWN:
newHead = {'x': wormCoords[HEAD]['x'], 'y': wormCoords[HEAD]['y'] + 1}
elif direction == LEFT:
newHead = {'x': wormCoords[HEAD]['x'] - 1, 'y': wormCoords[HEAD]['y']}
elif direction == RIGHT:
newHead = {'x': wormCoords[HEAD]['x'] + 1, 'y': wormCoords[HEAD]['y']}
wormCoords.insert(0, newHead)
#END MOVEMENT CODE
DISPLAYSURF.fill(BGCOLOR)
drawGrid()
drawWorm(wormCoords)
drawApple(apple)
drawScore(len(wormCoords) - 3)
pygame.display.update()
run_once(loop)
FPSCLOCK.tick(FPS)
Logic to read IRC and run the event loop was added to the other game screens, and to the game termination function:
In drawScore, we also draw the last 3 lines of chat. in a small font. This way you know how big the lag in the stream is, and whose last chat command is currently shown on the screen.
def drawScore(score):
scoreSurf = BASICFONT.render('Score: %s' % (score), True, WHITE)
scoreRect = scoreSurf.get_rect()
scoreRect.topleft = (WINDOWWIDTH - 120, 10)
DISPLAYSURF.blit(scoreSurf, scoreRect)
topleft=scoreRect.bottomleft
for msg in bot.msgs[-3:]:
IRCSurf = CHATFONT.render(msg, True, WHITE)
IRCRect = IRCSurf.get_rect()
IRCRect.topleft = topleft
topleft= IRCRect.bottomleft
DISPLAYSURF.blit(IRCSurf, IRCRect)
def drawWorm(wormCoords):
for coord in wormCoords:
x = coord['x'] * CELLSIZE
y = coord['y'] * CELLSIZE
wormSegmentRect = pygame.Rect(x, y, CELLSIZE, CELLSIZE)
pygame.draw.rect(DISPLAYSURF, DARKGREEN, wormSegmentRect)
wormInnerSegmentRect = pygame.Rect(x + 4, y + 4, CELLSIZE - 8, CELLSIZE - 8)
pygame.draw.rect(DISPLAYSURF, GREEN, wormInnerSegmentRect)
def drawApple(coord):
x = coord['x'] * CELLSIZE
y = coord['y'] * CELLSIZE
appleRect = pygame.Rect(x, y, CELLSIZE, CELLSIZE)
pygame.draw.rect(DISPLAYSURF, RED, appleRect)
def drawGrid():
for x in range(0, WINDOWWIDTH, CELLSIZE): # draw vertical lines
pygame.draw.line(DISPLAYSURF, DARKGRAY, (x, 0), (x, WINDOWHEIGHT))
for y in range(0, WINDOWHEIGHT, CELLSIZE): # draw horizontal lines
pygame.draw.line(DISPLAYSURF, DARKGRAY, (0, y), (WINDOWWIDTH, y))
if __name__ == '__main__':
main()
This is the end of “twitch plays wormy”. You can copy all the segments into a python file together and run the game now. If you compare this code to the original wormy.py, you will see that it’s mostly the same, the changes are very limited - you can easily add the code to different games, as long as they aren’t quick-reaction-skill action games. If they are action games, you need to slow down the game to handle the lag. The code that reads chat only runs inside the event loop, so only when run_once(loop) is called, and interacts with the rest of the game by posting ordinary pygame events.
That’s one rather famous type of twitch integration out of the way. But another type is far more common:
Twitch Voting
Most of the time, chat doesn’t play the game - the streamer plays it, and chat comments on gameplay or talks to the streamer. Chat integration is not about sending button presses to the game, or about moment-to-moment gameplay, but about asking chatters to help make occasional, high-level decisions: Which hat should I wear? Should I go left or right? Which card should I add to this deck?
So here is a simple poll/voting pygame app. It’s not integrated into an existing game this time. Asking twitch chat to vote on decisions is something that needs to fit into the game UI and overall game design, and I did not want to retrofit a complex game for this tutorial.
We start with the same imports:
import pygame
import asyncio
import irc, irc.client_aio
import sys
import collections
# we need collections this time too
if len(sys.argv)!=3:
print("please get a twitch auth token from https://twitchapps.com/tmi/")
sys.exit(1)
username=sys.argv[1]
token=sys.argv[2]
irc_pw=token.strip()
irc_user=username.strip()
assert(irc_pw.startswith("oauth:"))
This time, the AsyncIRC class is more complex. In addition to a handler for chat commands, there is also a handler for poll votes. In addition to a list of chat messages, there are also fields for counting votes, counting voters, voting options, and so on.
def parse_tags(event):
if hasattr(event,"tags_dict"):
return event.tags_dict
tags={}
for tag in event.tags:
tags[tag["key"]]=tag["value"]
event.tags_dict=tags
try:
event.display_name=tags["display-name"]
except:
pass
class AsyncIRC(object):
def __init__(self, loop, command_prefix=">", poll_prefix="#"):
self.loop = loop or asyncio.get_event_loop()
self.reactor=irc.client_aio.AioReactor(loop=self.loop)
self.connection=self.reactor.server()
self.commands=dict()
self.command_prefix=command_prefix
self.poll_prefix=poll_prefix
self.msgs=[]
self.has_voted=[]
self.poll_votes=0
self.poll_count={}
self.poll_options=[]
self.poll_running=False
def dispatch_cmd(connection, event):
content=event.arguments[0]
if content.startswith(self.command_prefix):
sans_prefix=content[len(self.command_prefix):]
try:
cmd, *args = sans_prefix.split()
except:
cmd = sans_prefix
args=[]
cmd=cmd.lower()
if cmd in self.commands:
parse_tags(event)
return self.commands[cmd](connection, event, *args)
else:
print('invalid command: ' + cmd)
def count_poll(connection, event):
content=event.arguments[0]
if self.poll_running and content.startswith(self.poll_prefix):
sans_prefix=content[len(self.poll_prefix):].strip().lower()
if sans_prefix in self.poll_options:
if not event.source in self.has_voted:
self.poll_count[sans_prefix]+=1
self.poll_votes+=1
# self.has_voted.append(event.source)
#commented out for testing
#so you can vote twice
self.reactor.add_global_handler("pubmsg", dispatch_cmd, -1)
self.reactor.add_global_handler("pubmsg", count_poll, -1)
def start_poll(self, title, options):
self.poll_votes=0
self.has_voted=[]
self.poll_count=collections.defaultdict(int)
self.poll_options=[str(option).strip().lower() for option in options]
self.poll_running=True
self.poll_title=title
message=f"Poll: {title} - "
message+=", ".join([self.poll_prefix+option for option in options])
self.connection.privmsg(self.channel, message)
self.connection.privmsg(self.channel, "voting starts NOW")
def stop_poll(self):
self.poll_running=False
best_option=None
for option in self.poll_options:
if self.poll_count[option] > self.poll_count[best_option]:
best_option=option
nvotes=self.poll_count[best_option]
percent=int(100*nvotes/self.poll_votes)
message=f"poll {self.poll_title} ended - {best_option} won with {nvotes} votes ({percent}%)"
self.connection.privmsg(self.channel, message)
def handle(self, event_type, *args, **kwargs):
def decorator(fun):
self.reactor.add_global_handler(event_type, fun, *args, **kwargs)
return fun
return decorator
def broadcast(self, message):
self.connection.privmsg(self.channel, message)
def command(self, command):
def decorator(fun):
self.commands[command]=fun
return fun
return decorator
def connect(self, server, nickname, join_channel,
password=None, port=6667):
def on_connect(connection, event):
connection.cap('REQ', ':twitch.tv/membership')
connection.cap('REQ', ':twitch.tv/tags')
connection.cap('REQ', ':twitch.tv/commands')
connection.join(join_channel)
if join_channel:
self.channel=join_channel
self.reactor.add_global_handler("welcome", on_connect)
self.channel=join_channel
coro=self.connection.connect(server=server, port=port, nickname=nickname, password=password)
self.loop.run_until_complete(coro)
loop = asyncio.get_event_loop()
bot=AsyncIRC(loop)
The actual UI for this twitch polling app is rather short. When the user presses Return, the poll starts, if it isn’t already running. The duration of the poll is set by the variable poll_ticks in the game loop, not in the event loop. While the poll hasn’t been stopped, it keeps on counting on the event loop.
Since the vote counting happens only inside run_once(loop), there are no race conditions between polling and game loop, and we don’t need to worry about race conditions for votes that arrive just as the poll ends, or votes that arrive while the UI is being drawn.
poll_ticks=-1
my_options=["A", "B", "C"]
pygame.init()
screen_size=640,480
screen=pygame.display.set_mode(screen_size)
clock=pygame.time.Clock()
running=True
flying_frames=0
best=0
color=(50,50,50)
font=pygame.font.SysFont("Helvetica Neue,Helvetica,Ubuntu Sans,Bitstream Vera Sans,DejaVu Sans,Latin Modern Sans,Liberation Sans,Nimbus Sans L,Noto Sans,Calibri,Futura,Beteckna,Arial", 16)
while running:
clock.tick(30)
events=pygame.event.get()
for e in events:
if e.type==pygame.QUIT:
running=False
if e.type==pygame.KEYDOWN and e.key==pygame.K_RETURN:
if poll_ticks<=0:
bot.start_poll("Test Vote", my_options)
poll_ticks=30*45 #FPS*45 seconds
screen.fill((255,255,255))
if poll_ticks>=0:
if poll_ticks>0:
poll_seconds=poll_ticks//30
time_text=font.render(f"{poll_seconds}s remaining", True, (0,0,0))
else:
time_text=font.render("results:", True, (0,0,0))
screen.blit(time_text,(10,30))
for i, option in enumerate(bot.poll_options):
nvotes=bot.poll_count[option]
if bot.poll_votes>0:
percent=int(100*nvotes/bot.poll_votes)
else:
percent=0
option_text=font.render(
f"{bot.poll_prefix}{option}: {nvotes} ({percent}%)",
True,
(0,0,0))
y_pos=100+i*30
screen.blit(option_text,(10,y_pos))
if bot.poll_votes < 20:
bar_length=nvotes*20
else:
bar_length=percent*5
pygame.draw.rect(screen, color,
pygame.Rect(100, y_pos,
bar_length,25))
if poll_ticks>0:
poll_ticks-=1
if poll_ticks==0:
bot.stop_poll()
fps=clock.get_fps()
pygame.display.update()
run_once(loop)
while len(asyncio.Task.all_tasks(loop)):
run_once(loop)
loop.shutdown_asyncgens()
loop.close()
print("Thank you for playing!")
Now you can add voting to your own pygame games. Maybe you shouldn’t call the options “1”, “2”, and “3”, but give them descriptive names depending on the current situation.
In this post I will show you how to do the same thing again, but with threads, for the sake of comparison. The first example had no networking, the second sent HTTP requests from the game loop with the requests module, and the third used asyncio and aiohttp.
If you want to take a look at the previous examples, look here!
This version uses requests and the threading module in Python 3. It should not produce any lag in the game loop. Apart from the HUD, the gameplay experience is the same. Both threading and asyncio are fine choices if you want to add background i/o to your game.
This time we’ll walk through the code piece by piece.
We import requests, threading and queue this time. The same requests module has already been used in the networking example without concurrency. The nice thing about threading is that we can just do normal blocking i/o, but in another thread, without changing the code too much.
The queue module contains a data structure we use to communicate between threads. It is important that you don’t just use a list, and that you use the right queue data structure for multi-threaded code. There is a different queue data structure for async code.
This bit of the code is the same as in the other versions.
posting_queue=queue.Queue()
def achievement_posting_loop():
while running or posting_queue.qsize():
payload=posting_queue.get()
if payload is None:
posting_queue.task_done()
break
try:
for i in range(10):
response = requests.post("https://httpbin.org/post", data=payload)
if response.status_code == requests.codes.ok:
print("achievement posted")
print(response.content)
else:
print("something went wrong")
except requests.exceptions.ConnectionError:
print("something went wrong")
posting_queue.task_done()
print("Thread Ending")
background_thread=threading.Thread(target=achievement_posting_loop)
background_thread.start()
Here we create a queue, define a function, and start a thread that runs the function.
In the thread, we poll the queue for new items as long as the running variable is true, or as long as there are still items in the queue. This way, we cans set running to false, and the posting loop will first work through the queue before exiting. On the other hand, if the queue is empty, but running is true, we wait until there is another item out into the queue.
By calling task_done(), we can communicate to the other thread that the queue item has been processed. This enables the other thread to call join() on the queue, to wait until the queue is emptied and all items taken from the queue have been processed.
flying_frames=0
best=0
color=(50,50,50)
font=pygame.font.SysFont("Helvetica Neue,Helvetica,Ubuntu Sans,Bitstream Vera Sans,DejaVu Sans,Latin Modern Sans,Liberation Sans,Nimbus Sans L,Noto Sans,Calibri,Futura,Beteckna,Arial", 16)
while running:
clock.tick(30)
events=pygame.event.get()
for e in events:
if e.type==pygame.QUIT:
running=False
if e.type==pygame.KEYDOWN and e.key==pygame.K_UP:
blob_yspeed+=10
# ...
# move sprites around, collision detection, etc
blob_yposition+=blob_yspeed
blob_yspeed-=gravity
if blob_yposition<=30:
blob_yspeed=0
blob_yposition=30
flying_frames=0
else:
flying_frames+=1
if flying_frames>best:
best=flying_frames
if not achievement and best>300:
# 10 seconds
payload=dict(name=getpass.getuser(),
title="ten seconds",
subtitle="last for ten seconds without touching the ground")
posting_queue.put(payload)
achievement=True
color=(100,0,0)
As you can see, there is not much to do here apart from putting an item into the queue.
Finally, when the player quits the game, we signal the background thread to stop, and wait for the queue elements to be processed. The achievement_posting_loop() function will return on receiving None in the queue, so we don’t have to “kill” the background thread, bit we can wait for it to finish with background_thread.join().
And that’s it. This is the threading based version of the game.
username=getpass.getuser()
payload=dict(
username="Achievement Example Code Bot",
content=f"{username} stayed up in the air for ten seconds without touching the ground"
)
response = requests.post(MY_DISCORD_WEBHOOK_URL, json=payload)
In part five, we will leave this example game behind, and go back to asyncio, to implement a “real-world” example of asynchronous game networking: Twitch chat integration!
This is something I have seen once in production, and a couple of times in tutorials. It goes like this: You have this library built on asyncio you want to use in your program. Your program is not parallel or concurrent at all, so you could just let everything run in lock-step. You start building your program in a deterministic fashion, from the bottom up. You run coroutines in the event loop, but only one at a time, until they complete.
And as long as your program is single-threaded, this works, kind of, except you gain no benefits from using asyncio here. With loop.run_until_complete() you turn your non-blocking code into function calls that block until all the data has been sent, but now everything is also running inside an under-utilised event loop. This in an insiduous noob trap, as it does work, just very badly!
But as soon as you want to use any actual concurrency, you see how you hamstring yourself with repeated loop.run_until_complete(). You cannot start two post_status() operations at the same time on two different connections now.
If your own api was also made up out of async def coroutines, you could use asyncio.gather() or asyncio.run_coroutine_threadsafe().
If most functions in your API call loop.run_until_complete(), or if one function calls two other functions that call loop.run_until_complete(), consider making them coroutines instead, and use await.
This is how you could organise your module instead:
Here is a simple multithreaded python program to demonstrate race conditions. One thread sequentially increments my_numbers["b"], while two more threads increment my_numbers["a"] in parallel. Can you guess what this program will print out?
import threading
my_numbers=dict(a=0, b=0)
def incrementer(key, times):
for i in range(times):
my_numbers[key]+=1
thread_1=threading.Thread(target=incrementer, args=("a", 5_000_000))
thread_2=threading.Thread(target=incrementer, args=("a", 5_000_000))
thread_3=threading.Thread(target=incrementer, args=("b", 10_000_000))
thread_1.start()
thread_2.start()
thread_3.start()
thread_1.join()
thread_2.join()
thread_3.join()
print(my_numbers)
Even a simple line of code like my_numbers[key]+=1 is made up of multiple operations in the python interpreter. Getting the value of my_numbers, resolving the __getitem__ method, getting the value, incrementing, et cetera. CPython’s GIL guarantees that all this will never lead to segmentation faults, premature garbage collection, or corrupted memory. It does not guarantee that everything executes in a sensible order. Too often, both threads read my_numbers[key], increment the value at the same time, and write my_numbers[key]+1 back, so one incrementing operation was superfluous. Even worse, there could be a situation where one thread wants to write my_numbers[key]+1 back, but it gets pre-empted. The other thread runs on, incrementing the value hundreds of times, until it itself gets pre-empted, and the first thread finally writes my_numbers[key]+1 back, even though at this time the number is much less that what the other thread had already computed.
It’s not as easy to make the same mistake with asyncio, but if you try hard enough, you can also shoot yourself in the foot. At least, you can spot the bug in this version of the code right away: There is an await in between reading a value and writing back the result.
import asyncio
import random
my_numbers=dict(a=0, b=0)
async def bad_async_incrementer(key, times):
await asyncio.sleep(random.random())
for i in range(times):
x=my_numbers[key]
await asyncio.sleep(0.01)
my_numbers[key]=x+1
coro1=bad_async_incrementer("a", 50)
coro2=bad_async_incrementer("a", 50)
coro3=bad_async_incrementer("b", 100)
loop=asyncio.get_event_loop()
loop.run_until_complete(asyncio.gather(coro1,coro2,coro3))
print(my_numbers)
I had to add random sleep times to overcome the somewhat deterministic nature of the event loop scheduling to make this example work, but in a real-world application, the timing of network input won’t be deterministic either, and you can have multiple await statements in one coroutine.
It’s probably best not to read and write global state in threads or async tasks. You should rather use local or thread-local variables. If you really have to modify global state, wrap all your critical sections in locks!
Resource Conflicts
I don’t know what happens when two threads are trying to read from or write to the same socket in a multi-threaded python program. Maybe the input gets duplicated, maybe the output is interleaved, maybe there is some low-level locking. Even if the underlying OS operations are atomic, I have no clue if the Python standard library maybe splits the data up and processes it via multiple system calls. If you write code on top of well-behaved libraries (which correctly lock a socket before while reading or writing) your code could still invoke multiple of these “atomic” function calls in a row from different threads, leading to interleaved input or output.
When two coroutine tasks inside the same event loop await input from the same source at the same time, the result is usually this: The event loop does not know which one task to wake up next, and raises an exception.
If you have multiple tasks that want to send requests or do back-and-forth handshakes over the same connection, they each need to acquire a lock on the connection before they can communicate, and release it afterwards. Otherwise, your scheduler might raise an exception if you are lucky, or as above, the data coming from different threads could be interleaved or switched around! You must avoid resource conflicts by correctly using locks, and incorrect use of locks can cause deadlocks.
This might not look like a common occurrence in client software, but every time you have a bi-directional request-response protocol, like JSON-RPC, or a chat service where multiple users can send messages, the protocol is already more complicated than the simple, deterministic lock-step of HTTP.
Even though asyncio is completely single-threaded, it still provides concurrency. You will fave to deal with the familiar problems you know and hate from multi-threaded concurrency: Race conditions and deadlocks. The asyncio module does not help you with timing and correctness, is not a way to sidestep the GIL for CPU-heavy multi-core parallel computation, and will not make your programs easier to read. It is just a standardised API for concurrent, single-threaded, asynchronous I/O.
Architecture and Composability
Broadly speaking, you can’t call asynchronous code, as in code marked with async def from regular synchronous code. That is also true in other asynchronous, event-based architectures like node.js, and shown with a table like this:
in synch code
in async code
can call synch code
yes
yes
can call async code
NO
yes
This is technically correct, but at the same time somewhat misleading. While you can call synchronous code from async code, and you cannot use await outside of an async function, in practice you cannot just invoke any old bit of synchronous code from async code running inside an event loop. Any blocking I/O inside a coroutine will cause the whole event loop to block. That won’t be a problem if you debug your application with a single client, or a single task in your event loop, but it will come back to haunt you in production. Python doesn’t track which functions are pure functions, which functions have side effects, and whether they cause I/O or other system calls.
If your “synchronous” code is actually creating threads, you have to be extra careful, as Pythons thread synchronisation mechanisms and data structures in Python are different from those in asyncio. If your async code calls synchronous code that invokes callbacks, you might need to make sure that these callbacks are scheduled as tasks in your event loop again. Probably that means using as callbacks synchronous stubs that create coroutines and submit them to your event loop to be executed as tasks. It gets hairy fast.
Similarly, you can just evaluate a coroutine inside synchronous code if you really want to, by creating a new event loop and using loop.run_until_complete(coroutine). But you probably shouldn’t. If you are inside async code, you should just use await instead of messing with the loop. The worst-case scenario here is creating new loop from synchronous code that was called from async code, causing the outer event loop to hang until the inner event loop has been completed. This way, as long as any task in the inner loop is waiting, no task in the outer loop can do anything, even if there is input available.
As a general rule, your asyncio programs should use one thread and one event loop. If you absolutely have to use multiple threads, you should still only use one loop, and have that run in its own thread, and use thread-safe methods to submit tasks to it. Don’t have multiple loops in multiple threads, and don’t call loop.run_until_complete(coroutine) or loop.run_forever() from different threads.
If you are using a GUI toolkit with asyncio, you will probably have to spawn one GUI thread, and one “worker” thread with the event loop. You could also try handling UI events inside the event loop, or writing your own GUI mainloop that periodically calls the event loop (like my own approach to using asyncio with pygame at 30/60 FPS).
You get the most out of asyncio if you run a single-threaded server with only one event loop that handles all concurrency for you, and any long-running computations are either done by a fancy message queue middleware, or with concurrent.futures.ProcessPoolExecutor.
It gets worse. Application code might do weird things like calling your async library from a thread, or calling your threaded code from a coroutine, but application code might also use something like gevent, twisted or tornado - async frameworks from the Python 2 era. Gevent adds green threads to the Python interpreter, and monkey-patches common i/o operations to allow task switching while waiting on blocking input. Twisted implemented its own event loop, but can now be used on top of a Python 3.6 SelectorEventLoop. The same goes for Tornado.
If your library monkey-patches i/o operations, that could create problems when you use more than one of them. If your library uses its own event loop, or even worse, implements its own event-based scheduling, then two different event-based libraries will block each other.
If you use a non-standard event loop/scheduler, it better be compatible with asyncio coroutines, and you better make sure to schedule all tasks in the same loop. If one library uses the default asyncio loop via get_event_loop(), and another creates a new one with Twisted or Tornado, you have a problem, and the tasks from these two libraries will not be interleaved.
In the future, we might get API improvements for=asyncio, but for backward compatibility, everybody has to support the old API. Somebody might write a nice, stable, thread-like interface on top of asyncio, like thredo (https://github.com/dabeaz/thredo), but if such an interface will need to have a way to call async code that uses the low-level asyncio module. Otherwise, you will get the same problems again, with the event loop and coroutines inaccessible and hidden behind a swanky interface.
Best Practices
If you are doing request-response-request-response things, wrap every request-response cycle in a lock to make sure another task doesn’t accidentally get the response to your request.
If you read multiple times from a socket, for instance a first you read a length field, and then you read until you have read that many bytes, then wrap the whole thing in a lock.
If you read and write global or shared data structures, then make sure to either use locks, or to not await in between reading and writing.
If you are writing a library, don’t hide away the async behind a synchronous API. Just write coroutines, and let the use create the event loop and schedule tasks.
If you provide a synchronous API, at least allow the user to pass in an event loop.
Do not create more than one event loop per program. You can have one per thread, but even that defeats the purpose of the event loop.
If you write your own high-level asyncio wrapper, please make it interoperable with plain asyncio.
This makes ffmpeg take in raw RGB frames of 640x480 pixels from stdin at 30 fps, and encode as both webm and mp4 so the files can be played both on sensible web browsers, and on Safari.
Safari cannot play webm (vp8+matroska). Chromium and Firefox can only play html5-standardised mp4 (h264+mp4) when you have nonfree codecs installed.
My browsers didn’t like playing vp9 or h265, although ffmpeg can produce them. That might be because I am not using the newest version of ffmpeg and the wiki is ahead of me, or because the encoding guide on the ffmpeg wiki doesn’t care about html5 video.
I tried to play the files I created with the above command with firefox, mobile safari, vlc, xine and mplayer, and all seem to work. This is the html tag to play them:
But Safari still won’t play that if you serve your video via
[user@host] python3.6 -m http.server
If it’s a <video> element, you need to serve the video with a server that supports streaming and can serve byte range requests. Otherwise mobile Safari will silently fail and display a blank video of zero duration, and you will endlessly try to find the right ffmpeg parameters to make it work. It worked fine when I put the files on my apache2 server.
All that just to serve ten seconds of video as 200kB mp4/webm clips instead of 3.5MB gifs.
Here is a list of game genres and games in these genres. Titles that were genre-defining have been marked in bold. Some genre-defining games were very novel innovative, others were influential because they were well-made, commercially successful, and standardised the “default” game mechanics for the next wave of notable games in the genre.
RTS: Herzog Zwei, Dune 2, Z, WarCraft, C&C, StarCraft, Age of Empires, Dune 2000, Total War, Stronghold, Total Annihilation, Supreme Commander, RUSE, Company of Heroes, Grey Goo, Tooth and Tail
FPS: 3D Monster Maze, Ultima Underworld, Wolfenstein 3D, Doom, Unreal, Shadow Warrior, Quake, Serious Sam, Half-Life, Halo, Battlefield, Doom 3
MOBA: Herzog Zwei, Desktop Tower Defense, Aeon of Strife, Defense of the Ancients, Demigod, Heroes of Newerth, League of Legends, Smite, AirMech, Strife, Awesomenauts, DOTA 2, Heroes of the Storm
Battle Royale: C&C: Sole Survivor, ARMA 2/DayZ, H1Z1, PUBG, The Culling, Fortnite, Radical Heights, Cuisine Royale, Russia Battlegrounds, Realm Royale, Totally Accurate Battlegrounds, Zeus’ Battlegrounds, Call of Duty: Black Ops 4
MOBA games started out with Aeon of Strife and Defense of the Ancients, mods for RTS games. The first generation of commercial MOBA games retained a lot of the DOTA baggage that carried over from Warcraft 3. League of Legends and Heroes of Newerth
were based on DOTA, while drastically expanding the amount of MOBA
content and adding in mechanics that only became possible in a custom
engine and not in the mod.
Once MOBAs had become established as a genre, games like Strife, DOTA 2 and Heroes of the Storm took the MOBA formula and tried to make it more accessible by taking out accidental complexity and twitch skill.
Around the same time, games like Awesomenauts and Smite used MOBA mechanics but changed the perspective.
Something similar is happening in the Battle Royale genre. It started with a mod for a mod for ARMA 2. Then standalone titles H1Z1: King of the Kill and PUBG came out.
Fortnite and Realm Royale are the simplified, streamlined and more accessible titles.
So
once somebody figures out how to make a top-down perspective Battle
Royale game, the genre will probably jump the shark and we can all move
on.
The Pattern
What seems to be happening to me is that we start with a primordial soup of prototypes and relatively loosely designed games that try out new mechanics, or story-driven games with minigames and set pieces.
A set of mechanics becomes the core of the new genre
Somebody makes a successful game based on these mechanics
Copycats try their hands at the genre by copying the successful game
Players become familiar with the conventions of the genre
A game applies top-down design and removes much of the unnecessary complexity from the genre
Simplified games lower the barrier to entry
At the end of this process, the “copycats” are not copycats, but developers making a new game in an established genre. They have a much larger “complexity budget“ to work with.
Slowly the historical baggage/“accidental“ complexity is replaced by designed and intentional complexity. Mechanics become simpler, while player strategies grow more sophisticated.
When games with lots of cobbled-together mechanics give rise to a genre with more and more streamlined mechanics, the complexity of the systems decreases, but the need to pay attention to the remaining mechanics grows.
I’m posting this to have these definitions handy later. These approaches are extremes, and not every game fits that neatly into one and only one corner.
Bottom-Up Game Design
You start with a simple, toy-like core loop, like a movement system, a physics engine, resource gathering, building and crafting. You work on this, to make the core interactions feel polished and satisfying.
Then you add different goals on top of this core:
building -> build a tall building, build a wide building, build a bridge over this gap, build a road from A to B
combat -> defend for X time, destroy all enemies, gather N resources (risk-reward trade-off if resources can be spent on health/damage), capture the flag
movement -> reach goal in X time, reach goal high up, dodge hazards, walk on narrow path/tightrope, jump over the pit
Games with combat or economy usually have the implicit goals of gathering more resources and defeating enemies, because these two will help you to accomplish other goals - unless you are under time pressure and have to prioritise short-term victory over long-term control over the game world.
Pure sandbox games and simulations like MineCraft, Omnibus Simulator 2, and SimCity don’t always have any explicit goals added to the mechanics, but there is always an explicit or implied failure state: Either losing your health/life (in MineCraft), or losing all resources and thus agency (in SimCity).
Good Examples of Bottom-Up Game Design:
Mushroom11
World of Goo
Legend of Zelda: Breath of the Wild
Trine
In all three examples, the mechanics were designed to be interesting first, and then goals and content/levels were designed around these mechanics.
Top-Down Game Design
You start with one core mechanic and one goal. Every other game mechanic in every system is carefully tuned with that one goal in mind. There are no runaway feedback loops, the game will eventually end one way or another. All Content, levels or auxilliary mechanics were designed (or had to be re-designed) after the main goal and core mechanic.
Good Examples of Top-Down Game Design:
Super Smash Bros - mechanic: punch other players; goal: push enemies off the stage
Super Crate Box - mechanic: shoot enemies to survive; goal: pick up more guns
Auro - mechanic: push enemies off the stage to survive and build combo; goal: reach the goal score
Threes - mechanic: slide cards to combine them and clear the board; goal: combine as many cards as possible
Canabalt - mechanic: jump over hazards; goal: survive as long as possible
These games would not work as well if you changed the goal. Super Crate Box is designed to be challenging when you have to jump around and adapt to a new weapon, not when you stand your ground. Auro is designed around building combos instead of taking on enemies one by one.
Maximalist Game Design
The game is not centred around a toy-like core, a core mechanic, or a goal state. Instead, the focus is on the characters, the story, or the game world. There are multiple loosely connected and sometimes overlapping systems for movement, combat, and resource gathering. There is no one “core mechanic”, instead the game is highly modal: At times, it switches into set-piece sections with special controls, multiple-choice conversations or turn-based combat. You can get through the game without paying too close attention to some systems, while other systems become very relevant every now and again.
The game is more a platform for different minigames and sub-systems, connected by an overarching plot, quest or level-up progression. Game mechanics are added because they serve the plot/lore/narrative design, not because they contribute to a core loop.
Sometimes whole systems of loosely connected mechanics like inventory management are put into a game just because people expect a AAA game to have inventory management. Other “doings“ are added into a game because players expect the setting to have them.
Persona 5 is set in Tokyo, so of course there is an arcade, an bath house and a maid cafe. In Red Dead Redemption 2 you have to feed your horse and brush its mane, and if you overeat, then your player character gets fat.
I used to go to this restaurant with a really delicious
pepperoncini pasta. It’s a pretty simple kind of pasta — just oil,
garlic, and peppers. Every single time I would order the pasta, the
waitress would utter, in the same practiced tone: “I’m sorry — it
doesn’t have very much stuff in it”.
In these games, loops usually either can’t spiral out of control, because long progression arcs are gated by plot points and not skill or resources, or if they do spiral out of control you can just grind a minor resource for a while to make the rest of the game slightly easier going forward.
Inner-Platform Game Design
This happens when you build a game on top of another game or engine, and you cannot remove all basic mechanics of the original genre, but you can always add new ones.
Examples:
PUBG
DOTA
Tetris written Z-Code
Both PUBG and DOTA kept many of the systems of survival and RTS games intact, added new mechanics on top, and tweaked the numbers. This made it necessary to design goals and content around these mechanics instead of removing them.
Top-down re-designs of such games can often radically simplify the game by keeping the goal and overall structure, but removing unneeded mechanics. The remaining mechanics then have to be re-balanced again.
I got my sister the new Pokemon game for Christmas - except it isn’t new. It is more like a high-definition remastered re-release of Pokemon Yellow, with some mechanics lifted from Pokemon Go to make it fresh, more accessible, and to have something that makes use of the console’s motion controls.
If you played Pokemon Red or Blue on the original Game Boy, you might be in for a surprise because now you can clearly see what the pixel art on that old 160x144 screen was meant to represent. If you haven’t played the original Pokemon games, you might not even know what you’re missing, or what was improved. It’s probably the most accessible - or most forgiving - game of the series yet, not counting spin-off games like Pokemon Go, Pokemon Snap, Mystery Dungeon, or Detective Pikachu.
If you played a Pokemon game before, Let’s Go is probably not reason enough to buy a Nintendo Switch - but Mario Kart 8 totally is, unless you already have a Mario Kart game on your GameCube, 3DS, Wii, or Wii U.
My sister is happy with the new Pokemon game anyway, because the battery in her old Pokemon Yellow cartridge died back in 2004.
Game Design
All the small changes made in Pokemon Let’s Go compared to the gameplay of Pokemon Yellow are connected, and they add up to a significant reduction in depth and difficulty:
You can see wild Pokemon roam the tall grass
There are no random encounters or fights against wild Pokemon
Catching wild Pokemon works like it does in Pokemon Go
You can swap out Pokemon between storage and your party at any time outside of battle
Techniques like “cut” or “surf“ do not take up the slot of an attack
XP are shared between Pokemon in your party
All of these changes make it more like Pokemon Go:
You see Pokemon on the street and can to choose whether to catch them
You don’t fight wild Pokemon
There is no fixed “party“, you can choose Pokemon for each battle
The most obvious change in the dynamics of Let’s Go compared to Yellow is that battles against wild Pokemon are no longer threatening or dangerous: You can avoid them, they don’t damage your party, and if a Pokemon in your party is damaged, you can just swap it out with a healthy one from storage.
You don’t need to grind dozens of common Pokemon to encounter a rare one. You don’t need to carry around a water Pokemon in case you need to use “surf”.
Let’s compare Pokemon Let’s Go to other JRPG games:
In almost every JRPG game ever your player character and party members each have their own skills, HP, mana, experience and equipment slots, but shared inventory. Fights are turn-based many-vs-many, where your starting line-up consists of three or fours out of however many people you currently have. Some attacks, buffs or heals are area-of-effect and affect all fighters on one side. You need some area-of-effect skills to deal with crowds, and some heavy-hitting attacks to take down bosses. Healing during combat takes up one turn for one fighter, healing outside of combat is cheap. There is some amount of strategy in coordinating the attacks, skills, buffs and heals of your party members. When your player character dies in combat, it’s game over and you respawn or have to reload. Other party members can be revived. Expect to grind a lot to level all your party members, until they leave the party for plot reasons.
Pokemon Red/Blue/Yellow has random encounters in tall grass and dungeons, limited PP (power points) for moves, and fights against wild Pokemon to catch them or gain XP. Your player character does not have HP or gain XP, only your Pokemon do. When all your Pokemon are beaten, you “die“ and re-spawn at the last Pokemon Centre you visited. Every fight is one-on-one, and you can swap out Pokemon when one gets defeated, or when it runs out of PP. Since PP are not per-Pokemon, but counting down for each skill, e.g. ”15 splashes left on this Magikarp”, they are very predictable, and make it clear when you should save your stronger attacks for stronger enemies. Healing in a Pokemon Centre fully restores both HP and PP.
To advance, in addition to story events and puzzle-solving, you need a party of Pokemon that can get you through a dungeon area without dying, and that means ending battles quickly, efficiently, and without much HP loss. Before you set out into an unknown area or a dungeon, you need to select the right Pokemon for your party. One of them is probably your starter, because you always have it and it’s overlevelled, but your starter’s PP are precious. You need one or two Pokemon of the type that hard-counters the Pokemon in this area, and maybe you have to catch and/or train them first.
The levelling systems of Persona 5 are too complicated to describe in depth here. There is the “real world“ where you go to school or hang out with friends, and the collective unconscious/”Metaverse” where you fight with your “Personas”, which are like Pokemon based on the Archetypes of Jungian psychology and mythology, except the Shin Megami Tensei series is actually older than Pokemon and did it first.
In the real world, you explore the city after school, spend money in shops, run errands, and hang out with friends. You can level up your “soft skills” to get better at hanging out with friends. Hanging out with friends advances side plots and side quests, and levels up your “social links” with friends, granting them skills in the Metaverse.
In the metaverse, you fight other personas with your own. You gain XP to level up your character, collect money from battles that you can spend in the real world, catch new personas, and level up personas. Unlike Pokemon, Personas are not independent beings, but aspects of our collective unconscious or some other psychobabble, so they don’t have their own HP and mana (or “SP”, skill points) values. Party members gain the strengths and weaknesses of their personas, and take hits to their own HP. Your player character can have dozens of personas, but having more personas is not a health advantage. Because SP/mana are also tied to your player character, more personas are also not helping you with mana. You are only ever as strong as your one best persona. Some persona skills use more mana than others, and mana is hard to come by. While there are loads and loads of healing spells to replenish HP for you and your party, once your party runs out of mana and mana-replenishing items, your dungeon run is essentially over. Fortunately, Persona 5 has no random encounters, and you can often sneak past enemies to save your resources for boss fights.
The battle system is many-on-many, similar to most JRPGs. Limited SP in Persona 5 essentially take the role of per-skill limited PP in Pokemon. The resource scarcity of Persona 5 is exacerbated by the real-world/Metaverse system: You can’t buy healing items is a Metaverse dungeon, only in the real world, but you can get real money from fights in the Metaverse. Entering the metaverse is in itself taking up a valuable resource: time. You and your friends could have spent the afternoon doing sports, finishing your homework for school, or going for a walk in the park. Every time enter the Metaverse, it’s an afternoon down the drain, an afternoon that you could have spent trying to hook up with an older woman. Every time you leave a dungeon you cut your losses.
Compared to Persona 5, Pokemon Red/Blue/Yellow was not nearly as punishing. You can usually just leave an area and go back to the last Pokemon Centre, you can grind random encounters in the tall grass for a bit without losing out on anything (Except hours of your own time in real life that you could spend doing sports or seducing older women, if that’s your thing. Maybe Persona 5 was trying to tell me something.)
If can see that you will you run out of HP or PP before you finish the dungeon, you can just turn around while your Pokemon are still somewhat healthy, and make a beeline for the Pokemon Centre.
This part was longer than I expected: Back to Pokemon Let’s Go.
They could have made a game without motion controls where battling wild Pokemon works the same as it did before, but you can see them and there are no random encounters. That would have made it possible to sneak past wild Pokemon, which would have made dungeons much easier, or at least more predictable.
Imagine the opposite: There are random encounters, but the random encounters have you catch Pokemon with motion controls like in Pokemon Go. That would be annoying, and it wouldn’t add much to the game. In the worst case, you run out of Pokeballs and just flee every random encounter. It’s not difficult or interesting. It’s just annoying now.
Once you decide on adopting the catching mechanics from Pokemon Go, you are locked in into a bunch of design changes that turn trainer battles into the only source of real danger and interesting decisions in the whole game. Now, as long as you win a trainer battle by a slight margin, you can just walk to Pokemon Centre and heal back up. If you run into a big wild Pokemon, it won’t hurt you. At this point, letting players swap out Pokemon remotely becomes a sensible design decision: Walking back to heal your Pokemon would just become another annoyance as a result of perverse optimisation. (I know you can fly back to a city in Pokemon Red/Blue/Yellow with a bird Pokemon, but then you might have to walk back into the wilderness through tall grass again to get back to where you were.)
You can still grind by catching wild Pokemon, it works similarly to Pokemon Go.
TL;DR for game designers: Adopting motion controls had a huge impact on the game design of Pokemon Let’s Go, spanning the levels of mechanics, dynamics, and aesthetics.
TL;DR for players: If this is your first JRPG or Pokemon game, give it a try! Otherwise meh…