Websocket PONG
I have been spending the past few months dedicating near all my coding time to exploring the latest techniques, testing new libraries, and conducting personal experiments with LLMs. So lets go the opposite direction and create a pong game with vanilla js, and learn web sockets and get a better feel for handling real time events. This is my first time creating a collaborative system so should be interesting.
I have a couple apps I would like to add collaborative capabilities to, and intend to sync diagram & spreadsheet editing across clients in real time. Building websocket pong seemed like a great way to introduce myself to websockets.
Initial thoughts
I jumped right into implementation to get my hands dirty. Using Bun was quite nice so far: 0 dependencies, typescript native capabilities, and clean straightforward syntax. I created the game room manager and was able to fairly easily group new sessions together in different rooms, felt great.
However as soon as I started trying to sync inputs I felt my lack of foundation. My initial idea was to have the client directly update it’s position for instant feedback, send the delta, then broadcast that to other sessions in the lobby. In my head this was simple and clean but was quickly humbled and decided against this purely reactive data flow.
Lets plan a little more.
Planning the approach
At the highest level I had:
** game setup **
- handle individual connections
- pair 2 players together
** in game **
- handle player interaction
- sync each players game state from server
I decided to have the server be the source of truth. I don’t care about cheating for this since it’s a lil test, but was thinking it would be cool to add mario kart style buffs & debuffs later. So while I could orchestrate this on the client side, keeping it all in the server and simply sending the game state on a tick seemed much simpler.
Prodding deeper
Game setup
This was pretty straightforward with a couple hash maps to track everything
Server loop
- Before thinking about game state I need to figure out how to setup the loop for the game(s).
- For now, always having the loop running and broadcasting to every room available is fine. It’ll allow me to test a single user’s paddle moves before worrying about the other’s and the ball.
- But then how do I manage the ball, could it be as simple as only updating the ball position if 2 players are in the room? Maybe that will help with DCs too.
Game state
- I think having the server being the source of truth will be simplest to start with and I can iterate from there
- In the name of simplicity, the player’s sessionId will be their sole identity
- Lets start with the server broadcasting game state at 60fps. Idk if this will be jittery or overkill but I want it to feel smooth and if I keep the game state tiny I think this should not be an issue networking wise
- Send the full game state for now, can optimize with just deltas later
Punting
- win / lose state
- point transition (it’s jarring but for now just immediately update the score & reset the ball)
- disconnects
Data flow
Game setup flow:
- server running a game loop
- server calculates new ball position
- server broadcasts to every player with their room’s game state
Server loop flow (interval of X ms):
- server iterates rooms
- server recalculates ball position (iff room full)
- check for paddle collision, reverse x velocity on hit
- check for left / right wall collision, update score and reset ball on hit
- check for top / bottom wall collision, reverse y velocity on hit
- server will just move ball to the collision point on hit. This avoids the edge case of 2 collisions on the same frame and since we’re aiming for 60fps I think the updates will be small enough this will not be too jarring
- server broadcasts game state
- client receives game state
- client renders new state
Game interaction flow:
- user interacts
- client sends move to server { dy: -0.05 }
- This is percentage based to decouple the game logic from rendering. This will make future debuffs like a “slowdown” even easier too, all I need is to add a multiplier.
- This way the server can throttle veleocity and down the line can apply debuffs if I ever introduce them
- I should throttle client updates, at the minimum to the same tick rate of the server. I wonder if allowing clients to update more often than the server will have any impact, theoretically I think no but maybe it will be good for network hiccups and the like?
- server patches user’s position
Psuedo schema
Game state
interface GameState {
player1: { y: number; score: number };
player2: { y: number; score: number };
ball: { x: number; y: number; vx: number; vy: number };
}
Client events
- User drags mouse 100px down on a 2000px tall window
- Client Websocket sends message:
{ sessionId, action:"player:move", payload: { dy:-.05 } }
Server handlers
const { sessionId, action, payload } = message;
const game = GameRoomManager.getGameBySessionId(sessionId);
const playerId = getPlayer(sessionId);
switch (action) {
case "player:move":
GameController.move(game, playerId, payload.dy);
break;
}
! GameController will just mutate game for now. Immutable feels like a bit much for the point of this project
Server loop
iterate rooms
skip empty
update ball position
on ceiling/floor/paddle hit:
reroute
on left/right wall hit:
score
reset ball
else
add plain velocity to ball
iterate room sessions
send game state
Game Logic
class GameController {
initialVelocity = { x: .02, y: .02 }
move(game: GameState, player, dy) {
constrain delta magnitude
apply delta
apply player bound constraints
}
tick(game: GameState) {
const ballDistance = pythag(dx,dy)
if(paddle collision){
move to collision point
ball.dx *= -1
} else if ( ceiling / floor hit ) {
move to collision point
ball.dy *= -1
} else if ( ball out of bounds ) {
score for player on opposite wall
reset ball to center & initialVelocity
} else {
move distance
}
}
}
Server Events - new client
-
client connects via websocket
-
server generates session id
-
server finds room for client
-
server sends client initial info
{ action: "game:init"; payload: { sessionId: SessionId; player: "player1" | "player2"; roomId: number; }; }
Server Events - tick
- server tick
- server computes new state
- server sends new state
{ action: "game:setState"; payload: GameState; }
UNFINISHED BLOG SECTION
At this point events are flowing smoothly between the frontend and backend. Still have to render everything client side but, the initial setup with websockets has been quite nice with Bun and keeping all messages typed.
UNFINISHED BLOG SECTION
TODO: Since I’m new to all this I want to just get things working with the clearest debugging flow possible, then I’ll improve the experience
- track time between ticks for more accurate updates
- client side prediction for smoother feel
Takeaways
- Bun.js was very smooth for this minimal, vanilla websocket build. Will definitely experiment with Bun more in the future.
- As much as I’d like to chalk my mistake with my reactive implementation up to unfamiliar territory, the server authoritative approach offers a simpler mental model with a seemingly easier path to debug.
- This project handled 60fps no problem, I even jacked it up to 240fps to take full advantage of my monitors refresh rate and it was buttery smooth.
- Percentage based coordinates felt like a win, I have no experience going the other way but I have no want
- Collision detection was the peskiest part of this. I’m looking forward to experimenting with more complex websocket systems in the future
- Once I properly planned the data flow & messages, setting up the websockets was easy. Though we’ll see how a higher complexity setup goes