Your first room
Two lines turn a normal game into a multiplayer one. net.join() picks the room, and net.me() tells everyone where you are.
import game, net
net.join("my-first-room") # anyone with this name plays with you
game.window(480, 360)
me = game.sprite("car", 240, 180, 44)
while game.playing():
if game.pressed("left"): me.x -= 5
if game.pressed("right"): me.x += 5
if game.pressed("up"): me.y -= 5
if game.pressed("down"): me.y += 5
net.me(me) # show my car to everyone
net.others() # ...and put their cars on my screen
game.frame(30)
How to test it on your own. Open PyWebLib in a second browser tab and run the same code there. Each tab is a separate player, so you can drive both and watch them move on each other's screens. Pick a room name nobody else will guess, or you may find a stranger already driving around in it.
Everyone else
net.others() hands you a list of the other players. Each one already has a sprite, created, moved and (when they leave) removed for you. You do not build it and you do not tidy it up.
for p in net.others():
print(p.name, "is at", p.x, p.y)
if me.touches(p): # collisions work exactly like a sprite
game.game_over("Crash!")
A player is a normal thing to poke at:
p.id— who they are, and it never changes while they are playing.p.name— what to call them on screen.p.x,p.y— where they are.p.sprite— the sprite drawing them, if you want to fiddle with it.me.touches(p)— the same collision check you already know.
You can send extra facts about yourself, and read them back off everybody else:
net.me(me, hp=3, ready=True) # ride along with my position
for p in net.others():
if p.get("hp", 0) <= 0:
print(p.name, "is out!")
Sharing one fact
Positions are per player. Sometimes the whole room needs to agree on one thing: who has the bomb, whose turn it is, what the score is. That is net.set() and net.get().
net.set("bomb", net.id) # I have the bomb
if net.get("bomb") == net.id:
print("Run!")
It holds anything you could print: text, numbers, True/False, lists and dictionaries. The rule is last write wins — if two players write the same key at the same moment, one of them quietly loses. The next section is about making sure that never happens.
Bomb tag
Everything above, as a real game. Drive into somebody to hand them the bomb.
import game, net, random
net.join("bomb-tag")
game.window(480, 360)
game.background("#101828")
me = game.sprite("car", random.randint(60, 420), random.randint(60, 300), 44)
info = game.label("", 240, 24, 16)
COOLDOWN = 30 # frames you must hold it for: 1 second at 30 fps
KNOCKBACK = 46 # pixels the two of you are shoved apart on a tag
def back_off(x, y):
# Push me away from a point. Without this the two cars are still touching
# the instant the bomb changes hands, so it would come straight back.
dx, dy = me.x - x, me.y - y
gap = (dx * dx + dy * dy) ** 0.5
if gap < 1: # dead centre on top of each other
dx, dy, gap = 1.0, 0.0, 1.0
me.x += dx / gap * KNOCKBACK
me.y += dy / gap * KNOCKBACK
cooldown = 0
had_bomb = False
while game.playing():
if game.pressed("left"): me.x -= 5
if game.pressed("right"): me.x += 5
if game.pressed("up"): me.y -= 5
if game.pressed("down"): me.y += 5
players = net.others()
holder = net.get("bomb")
# Nobody has the bomb yet? The player with the smallest id takes it. Every
# browser works that out the same way, so no one has to be the referee.
if holder is None and net.online():
ids = [p.id for p in players] + [net.id]
if net.id == min(ids):
net.set("bomb", net.id)
mine = (holder == net.id)
# Just been handed it? Jump back off whoever tagged me and start the
# cooldown, so it cannot bounce between two cars that are touching.
if mine and not had_bomb:
cooldown = COOLDOWN
for p in players:
if me.touches(p):
back_off(p.x, p.y)
break
had_bomb = mine
if cooldown > 0:
cooldown -= 1
want = "💣" if mine else "car"
if me.content != want:
me.content = want
# Only whoever HOLDS the bomb ever writes down who has it next, so two
# players can never disagree about where it is.
if mine and cooldown == 0:
for p in players:
if me.touches(p):
net.set("bomb", p.id)
back_off(p.x, p.y)
break
me.x = max(22, min(458, me.x))
me.y = max(22, min(338, me.y))
net.me(me)
if not net.online():
info.content = "Connecting..."
elif mine and cooldown > 0:
info.content = "You have the bomb! Hold it for " + str(cooldown // 30 + 1) + "..."
elif mine:
info.content = "You have the bomb! Run into someone."
else:
info.content = "Players: " + str(net.count()) + " - keep away from the bomb!"
game.frame(30)
Ideas to take it further: a countdown that ends the game for whoever is holding it, a score kept in net.set("scores", ...), or making the bomb holder faster than everyone else.
Who decides?
This is the one genuinely new idea in multiplayer, and bomb tag shows it twice.
Every browser is running its own copy of your program. They cannot all be in charge of the same fact, or they will disagree — two players would each think they had passed the bomb on. So for every shared fact, decide who is allowed to write it:
- Give one player the pen. Only the player holding the bomb writes who gets it next. Everyone else only reads. There is nothing to disagree about, because only one browser ever writes.
- Or let everyone work it out identically. Nobody owns the bomb at the start, so the rule is "smallest id takes it". Every browser sorts the same list of ids and reaches the same answer without anyone being asked.
The trap to avoid. Writing a shared value every frame from every player, like net.set("scores", ...) in the main loop for everybody. They will fight, the value will flicker, and it costs a fortune in messages. Let one player own each key.
Every call
net.join(room, name=None, rate=5)— join a room. Returns straight away and connects in the background.rateis how many times a second your position may be sent (1–20). The default 5 looks smooth at 30 fps and costs half what 10 does; raise it only for something twitchy.net.me(sprite, **extras)— publish my position and skin. Call it once per frame. Standing still costs nothing.net.others()— the other players, each with a sprite already on screen.net.set(key, value)/net.get(key, default=None)— one fact the whole room shares.net.id— my player id. Fixed for this browser tab.net.count()— how many players are here, me included.net.online()—Trueonce I am actually in the room.net.status()—"offline","joining","joined", or"unavailable"when this copy of PyWebLib has no multiplayer backend set up.net.room()— the room name I ended up in (tidied: spaces and punctuation become dashes).net.leave()— leave, and take everyone else's sprites off my screen.
What it costs
Worth knowing before you point a whole class at it. The pages themselves are free to host; the only thing that costs money is relaying the messages.
PyWebLib already does the two things that matter most. Updates are throttled to rate a second no matter how fast your loop runs, and an unchanged position is not resent, so a parked car is nearly free. Measured at the default rate=5: 123 calls to net.me() in a second while moving became 11 messages; parked, it became 2.
On Supabase (what this ships with)
Supabase charges per message received, not per message sent: one update in a room of four counts as five, because four people get a copy. So traffic grows with the square of the room size, and room size matters far more than tick rate. Roughly, with everyone moving most of the time, against the free tier's 2 million messages a month:
- You and three friends, a couple of hours a week — about 1.7M. Free.
- A code club: three rooms of four, an hour a week — about 2.6M. Needs the $25 plan.
- One class a week (eight rooms of four) — about 5.8M, so about $27.
- Five classes a week — about 29M, so about $84.
- One room of 30 — do not. The square is brutal: it is more traffic than the whole rest of this list put together.
On the Cloudflare relay
A relay does not have to bill you for the fan-out. PyWebLib ships one as a Cloudflare Durable Object (in worker/, one command to deploy). Only messages arriving in are charged, and incoming WebSocket messages are counted 20:1 on top of that, so the squared term disappears. Measured against the real code, relaying ten rounds of updates:
- Room of 4 — 36 messages relayed, 12 billed. Supabase would bill 48.
- Room of 16 — 960 relayed, 64 billed. Supabase would bill 1024.
The free plan allows 100,000 requests and 13,000 GB-s a day, and a class day costs roughly 17k and 3.1k. Five classes a week is free, and so is four lessons in a single day. Only a public, all-day game needs the $5 plan.
The trade is that it is a small server you deploy, against Supabase needing no code at all. Which one is running is picked automatically, and you can check with PWL.net.transport() in the browser console. Your Python never knows the difference.
Two levers, in order. Keep rooms small — on Supabase that is the squared term and it dominates everything else. Then drop rate to 4 or 5 for anything that is not twitchy. Doing both is the difference between free and a bill.
What it cannot do
- It is not cheat-proof. Every browser is trusted, so a determined student can write whatever position they like. That is fine for tag and terrible for anything competitive.
- Rooms are public. Anyone who guesses the name can join. Pick an odd one.
- No history. A room remembers nothing: join late and you see the room as it is now, not what happened before. Scores that must survive belong in
game.save()or a published game's leaderboard. - Up to 24 players are reported in a room, and everyone is dropped about four seconds after they go quiet.
- It needs the community backend. On a copy of PyWebLib without Supabase configured,
net.status()returns"unavailable"and every call quietly does nothing, so a program written withnetstill runs — alone.