Skip to content

Commit f73875f

Browse files
authored
Merge pull request #47 from healeycodes/post-2d-multiplayer
add 2d multiplayer post
2 parents 481576a + 17c09ad commit f73875f

File tree

4 files changed

+340
-0
lines changed

4 files changed

+340
-0
lines changed

data/posts.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const popularPosts = [
77

88
// Good posts/highly viewed posts (not in any specific order)
99
export const postStars = [
10+
"2d-multiplayer-from-scratch",
1011
"lisp-compiler-optimizations",
1112
"lisp-to-javascript-compiler",
1213
"compressing-cs2-demos",

data/projects.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ export default [
8888
desc: "Generate or convert random bytes into passphrases. A Rust port of niceware.",
8989
to: "/porting-niceware-to-rust",
9090
},
91+
{
92+
name: "dm-multiplayer",
93+
link: "https://github.com/healeycodes/dm-multiplayer",
94+
desc: "A game prototype for a 2D arena shooter.",
95+
to: "/2d-multiplayer-from-scratch"
96+
},
9197
{
9298
name: "deno-script-sandbox",
9399
link: "https://github.com/healeycodes/deno-script-sandbox",
Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
---
2+
title: "2D Multiplayer From Scratch"
3+
date: "2024-06-30"
4+
tags: ["go"]
5+
description: "Exploring patterns and systems for creating realtime browser games."
6+
---
7+
8+
I recently built a game prototype for a 2D arena shooter. Here are my notes on some of the patterns I used and the design of the server and client systems.
9+
10+
![dm-multiplayer gameplay – two players chasing and shooting each other.](gameplay.mp4)
11+
12+
The gameplay: players move their characters around a level, shooting bullets at other players. You gain points for hitting other players and lose points for getting hit. You can play it on desktop [here](https://dm-multiplayer.fly.dev/), and view the source code [on GitHub](https://github.com/healeycodes/dm-multiplayer).
13+
14+
The core problems are managing game objects, client/server synchronization, and drawing the game objects in the browser.
15+
16+
As game development projects go, the tools I used are quite high up the stack: a garbage-collected language and a web browser to run the client (but no game engine or other libraries).
17+
18+
The implementation time was roughly 4-5hrs over a few days. Not perfect code, but I think the separate parts fit together quite well.
19+
20+
## Managing Game Objects
21+
22+
Everything in my game (characters, bullets, and walls) is a game object. These game objects all implement an [`Entity` interface](https://github.com/healeycodes/dm-multiplayer/blob/feb1d99eda03313a35100be22078a1c6af37a416/main.go#L200) which allows them to be represented in the game state and eventually rendered for the player.
23+
24+
Using this interface, all game objects can be treated generically during the game loop during which the objects change position, collide, or stop existing.
25+
26+
A game has a level, and a level has entities.
27+
28+
```go
29+
type Level struct {
30+
width int
31+
height int
32+
entities EntityList
33+
}
34+
```
35+
36+
When a player connects to the game, a new character object is added to the list of entities.
37+
38+
Since the game is like a never ending deathmatch, players appear in the middle of the action. Choosing where to place them in the level takes a bit of thinking.
39+
40+
We don't want to place them inside another character (they would both be stuck), in a wall (they'd be stuck), or on top of a bullet (seems a bit unfair).
41+
42+
The character object is randomly placed, and a collision check is performed. If it's inside another object, another random position is checked, and then we repeat. To avoid blocking the game loop, these retries have a short timeout before a backoff wait.
43+
44+
```go
45+
func (el *EntityList) SpawnEntity(level *Level, entity Entity) {
46+
el.mu.Lock()
47+
defer el.mu.Unlock()
48+
49+
startTime := time.Now()
50+
for {
51+
52+
// Generate random position within level bounds
53+
x := rand.Float64() * float64(level.width-entity.Width())
54+
y := rand.Float64() * float64(level.height-entity.Height())
55+
56+
entity.SetPosition(x, y)
57+
58+
// Check for intersection with existing entities
59+
intersects := false
60+
for _, other := range el.entities {
61+
if entity.Id() != other.Id() {
62+
top, right, bottom, left := other.BoundingBox()
63+
entTop, entRight, entBottom, entLeft := entity.BoundingBox()
64+
if entLeft < right && entRight > left && entTop < bottom && entBottom > top {
65+
intersects = true
66+
break
67+
}
68+
}
69+
}
70+
71+
if !intersects {
72+
// Place the entity if no intersection
73+
el.entities = append(el.entities, entity)
74+
return
75+
}
76+
77+
// Check if 5ms has passed
78+
if time.Since(startTime) > 5*time.Millisecond {
79+
80+
// Unlock and wait for 100ms before trying again
81+
el.mu.Unlock()
82+
time.Sleep(100 * time.Millisecond)
83+
el.mu.Lock()
84+
startTime = time.Now()
85+
}
86+
}
87+
}
88+
```
89+
90+
This kind of collision checking (for every object, check for a collision with every other object) is a naive approach. It has quadratic time complexity, but it's the simplest to implement. Performance is not a concern here, as the number of game objects is bounded (<100) and each collision check has a cost on the order of nanoseconds.
91+
92+
Now that the character object is part of the game, it can be affected by the game loop.
93+
94+
The Game Programming Patterns book has [an entire chapter](https://gameprogrammingpatterns.com/game-loop.html) on game loops. It starts by succinctly describing the intent of this pattern:
95+
96+
> Decouple the progression of game time from user input and processor speed.
97+
98+
My game loop iterates over every entity, applies velocity to position, and performs a collision check. This check can trigger the `HandleCollision(*Level, Entity) CollisionResult` function of the `Entity` interface.
99+
100+
Let's take a look at the mighty game loop in action then.
101+
102+
```go
103+
func (level *Level) loop() {
104+
level.entities.Iterate(func(entity Entity) {
105+
106+
// Apply velocity to position
107+
newX := entity.X() + entity.VelocityX()
108+
newY := entity.Y() + entity.VelocityY()
109+
110+
// Collision checks
111+
blocked := false
112+
level.entities.Iterate(func(other Entity) {
113+
if entity.Id() != other.Id() {
114+
top, right, bottom, left := other.BoundingBox()
115+
if newX < right && newX+float64(entity.Width()) > left &&
116+
newY < bottom && newY+float64(entity.Height()) > top {
117+
blocked = blocked || entity.HandleCollision(level, other).Blocked
118+
}
119+
}
120+
})
121+
122+
if !blocked {
123+
entity.SetPosition(newX, newY)
124+
}
125+
126+
// Apply friction
127+
entity.SetVelocity(entity.VelocityX()*entity.Friction(), entity.VelocityY()*entity.Friction())
128+
})
129+
130+
// When bullets collide,
131+
// they set themselves to inactive
132+
// and they're cleared up here
133+
level.entities.RemoveInactive()
134+
}
135+
```
136+
137+
We can also take a look at the character object's collision handler. Here we're making sure that characters and walls can't overlap.
138+
139+
```go
140+
func (c *Character) HandleCollision(level *Level, entity Entity) CollisionResult {
141+
if entity.Type() == CharacterType || entity.Type() == WallType {
142+
return CollisionResult{
143+
Blocked: true,
144+
}
145+
}
146+
147+
return CollisionResult{
148+
Blocked: false,
149+
}
150+
}
151+
```
152+
153+
When players join or leave the game, it doesn't cause the game loop to move forward — it drives itself. Even things like player input and bullet spawning happen outside the game loop. Even things like player input and bullet spawning happens outside the game loop. For the prototype, I found that this separation made the flow of game time easier to reason about.
154+
155+
## Client/Server Synchronization
156+
157+
Players join the game by visiting a web page that connects them to the server via WebSocket. This HTML page is served by the game server as an embedded file. The main function is practically:
158+
159+
```go
160+
func main() {
161+
level := &Level{
162+
width: 800,
163+
height: 800,
164+
entities: EntityList{
165+
mu: sync.RWMutex{},
166+
entities: []Entity{},
167+
},
168+
}
169+
170+
game := &Game{id: "test", level: level}
171+
go gameLoop(game)
172+
173+
http.Handle("/", http.FileServer(http.FS(indexHTML)))
174+
http.HandleFunc("/ws", handleConnections)
175+
http.ListenAndServe(":8080", nil)
176+
}
177+
```
178+
179+
When a WebSocket connects, the handler on the server reads the control message containing the game id and player name, and then a character object is created.
180+
181+
Right after this, two long-lived goroutines run. One sends the serialized game objects to the client 60 times per second. The other listens for messages from the client (direction and shoot events).
182+
183+
When a direction event arrives, the character object's velocity is altered. When a shoot event arrives, a bullet object is created inside the character with a fixed direction (pointing towards where the player clicked). These changes cause effects during the next tick of the game loop.
184+
185+
My prototype doesn't handle reconnection so the character lives as long as the WebSocket is active. When there's a read or write error on the socket, the long-lived goroutines spawned by the player's connection are killed, and the character object is removed from the entity list.
186+
187+
The client is a 150 line `index.html` file (vanilla JavaScript, no build step, etc). It connects to the game server, receives updates 60 times a second, and draws game objects using the [Canvas API](https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API).
188+
189+
An update is a JSON-encoded list of game objects, kinda like a whole-world snapshot. It arrives as a socket event.
190+
191+
```jsx
192+
socket.onopen = () => {
193+
const name = getLocationName()
194+
195+
// When this arrives on the server
196+
// a character game object is created and spawned
197+
socket.send(JSON.stringify({ game: 'test', name }))
198+
}
199+
200+
socket.onmessage = (event) => {
201+
gameLevel = JSON.parse(event.data)
202+
203+
// Without a smooth connection, there will be jitter
204+
// similar to a FPS game, good internet is required.
205+
// This prototype has no client smoothing or prediction
206+
draw()
207+
}
208+
```
209+
210+
As I reflect on my prototype code now, I've decided that the following code that handles shoot events is *wonderful*. The player clicked somewhere on the canvas? They're taking a shot. Let the server know. Easy.
211+
212+
```jsx
213+
window.addEventListener('click', (event) => {
214+
const rect = canvas.getBoundingClientRect();
215+
const x = event.clientX - rect.left;
216+
const y = event.clientY - rect.top;
217+
const shoot = { x, y }
218+
socket.send(JSON.stringify({ type: 'shoot', shoot }));
219+
})
220+
```
221+
222+
The keyboard input handling – I'm a bit less happy with.
223+
224+
Players control their character with arrow keys or W/A/S/D. When they press a key down, an update is sent to the server with the current direction (which may be e.g. *left* or *left+up*). To continue moving, players can hold down that key, in which case, direction events are send every half-frame (~8ms) to the server.
225+
226+
When a player lets go of a key, events may stop being sent to the server (if there was a single key being pressed e.g. *left*), or the event might continue firing with a new direction (*left+up* just becomes *up*).
227+
228+
This direction event is converted to an (x, y) value, and the server multiplies it by a constant speed to set the character's velocity.
229+
230+
Here's all the keyboard listening code:
231+
232+
```jsx
233+
const keyState = {};
234+
let intervalId = null;
235+
236+
window.addEventListener('keydown', (event) => {
237+
238+
// Avoid input delay by sending this ASAP
239+
sendDirection();
240+
241+
keyState[event.key] = true;
242+
if (!intervalId) {
243+
244+
// Handle player holding down the key
245+
intervalId = setInterval(sendDirection, 8);
246+
}
247+
});
248+
249+
window.addEventListener('keyup', (event) => {
250+
keyState[event.key] = false;
251+
252+
// If no keys are pressed stop sending updates
253+
if (!Object.values(keyState).includes(true)) {
254+
clearInterval(intervalId);
255+
intervalId = null;
256+
257+
// Send one last update to stop movement
258+
sendDirection();
259+
}
260+
});
261+
262+
function sendDirection() {
263+
const direction = { x: 0, y: 0 };
264+
if (keyState['w'] || keyState['ArrowUp']) direction.y -= 1;
265+
if (keyState['a'] || keyState['ArrowLeft']) direction.x -= 1;
266+
if (keyState['s'] || keyState['ArrowDown']) direction.y += 1;
267+
if (keyState['d'] || keyState['ArrowRight']) direction.x += 1;
268+
socket.send(JSON.stringify({ type: 'direction', direction }));
269+
}
270+
```
271+
272+
This kinda "polling update" is how game engines like the Source Engine handle movement updates. It's great for real-time responsiveness but can lead to extra data being sent.
273+
274+
For my prototype, I think the code is more complex than necessary. The same responsiveness could probably be achieved by just sending the key up/down event and tracking it on the server without a polling event.
275+
276+
## Drawing Game Objects
277+
278+
The art style of the prototype is... the lack of an art style. the lack of an art style. The art is just black and red rectangles. Bordered for the player's character and bullets, solid for other players and their bullets. When a character is hit, they briefly turn red to register the hit.
279+
280+
The draw function is triggered whenever a new update arrives at the client (60 times per second).
281+
282+
```jsx
283+
function draw() {
284+
285+
// Clear screen
286+
ctx.clearRect(0, 0, canvas.width, canvas.height);
287+
288+
gameLevel.entities.forEach(entity => {
289+
if (entity.you) {
290+
291+
// Check for recent hit
292+
ctx.strokeStyle = entity.lastHit + 250 > gameLevel.timeMs ? 'red' : 'black';
293+
ctx.lineWidth = 1;
294+
const borderWidth = 1;
295+
296+
// Draw player's character
297+
ctx.strokeRect(
298+
entity.x + borderWidth / 2,
299+
entity.y + borderWidth / 2,
300+
entity.width - borderWidth,
301+
entity.height - borderWidth
302+
);
303+
} else {
304+
305+
// Check for recent hit
306+
ctx.fillStyle = entity.lastHit + 250 > gameLevel.timeMs ? 'red' : 'black';
307+
308+
// Draw other character
309+
ctx.fillRect(entity.x, entity.y, entity.width, entity.height);
310+
}
311+
});
312+
}
313+
```
314+
315+
Tying the visuals to the server updates requires users to have good internet to avoid jitter. This is similar to FPS games, except FPS games usually display player movements and actions before they are registered on the server. For my prototype, I don't have any [client-side prediction](https://en.wikipedia.org/wiki/Client-side_prediction) to hide the negative effects of high latency.
316+
317+
For the game to feel responsive, you must be in the same region as the server — if you are, it feels very responsive.
318+
319+
## Reflections
320+
321+
I've been trying to get better at iterating on quick game prototypes that I can playtest with my friends (mostly because it's a lot of fun). I learned a few things with this project that I'm definitely going to apply in the future.
322+
323+
I found the two types of client→server WebSocket message easy to work with. A control message to connect the player, and then follow up events with a `type` field. Having a separate goroutines running for each player (one to send updates, one to receive and process events) allowed me to manage less state. Some of my initial ideas that I discarded involved tracking all the connections in a structure and then looping over them.
324+
325+
Having the server handle as much state as possible made it quicker to get to a stage where I could start iterating. In the beginning, I didn't have a client, I just logged the game state as an ASCII grid to debug the game objects.
326+
327+
One thing I found tricky is that the list of entities is read and mutated from different goroutines. I had some crashes early on relating to concurrent access. I fixed the crashes by adding a read/write lock to the EntityList struct. The `Iterate` method has a read lock, and the `Spawn` and `Remove` methods have write locks.
328+
329+
Oh and people really liked having their name set to city + flag. Initially, I was going to use random names or let players pick their names but allowing people to jump right in and start shooting was way more fun and my friends could identify each other by their cities.
330+
331+
As for where I'm taking this project next, I'm not sure. My friends enjoyed spam shooting at each other and dodging bullets. I think the netcode is better than the average browser game which helps.
332+
333+
I'll either start implementing features (new guns, configurable characters, and weapons!) or research game algorithms like improved collision detection and write about that.
97.5 KB
Binary file not shown.

0 commit comments

Comments
 (0)