#websocket-server #multiplayer #browser #ez #text-based #player

ezbrowsergameserver

ez way to make multiplayer browser games using a websocket

4 releases

0.2.2 Dec 3, 2023
0.2.1 Oct 31, 2023
0.2.0 Oct 30, 2023
0.1.0 Oct 29, 2023

#72 in WebSocket

MIT/Apache

19KB
262 lines

ezbrowsergameserver

Ever wanted to make and self-host one of those simple, usually text-based, multiplayer browser games?

Me too! And this is a small library to hopefully make that as ez as possible :)

Getting started

Before you do anything, know that ezbrowsergameserver isn't a server for your website - it only provides the WebSocket which will be used to keep all your players updated and in sync.

If you have python3 installed, you can use the script examples/server.sh as a quick no-setup web server running on 0.0.0.0:8080.

For this example, you will use 0.0.0.0:8080/00_min.html:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Min - ezbrowsergameserver</title>
    <meta name="color-scheme" content="light dark">
    <script>
      var con = undefined;
      function createLobby() {
        joinLobby("new");
      }
      function joinLobby(id) {
        let ip;
        // if possible, use the same host that is hosting this html file.
        // assume localhost if not possible (i.e. opened the file directly)
        if (window.location.hostname) {
          ip = window.location.hostname;
        } else {
          ip = "0.0.0.0";
        }
        // connect to the websocket
        con = new WebSocket("ws://" + ip + ":8081");
        // handle incoming messages
        con.onmessage = (e) => {
          let msg = e.data;
          // allow the server to update the clients html
          bodyDiv.innerHTML = msg;
        };
        // when the websocket finishes connecting...
        con.onopen = () => {
          // join a lobby
          con.send(id);
        };
      }
    </script>
  </head>
  <body>
    <div id="bodyDiv">
      <p>Lobby ID: <input id="lobbyId"></p>
      <button onclick=createLobby()>Create new lobby</button>
      <button onclick=joinLobbyPressed()>Join lobby by ID</button>
      <script>
        function joinLobbyPressed() {
          joinLobby(lobbyId.value);
        }
      </script>
    </div>
  </body>
</html>

This will let you create a new lobby or join an existing one by entering its ID in the text input. The <script> sections use a WebSocket to connect to your game server - the server you are about to create using this library.

But before we get to your very first ezbrowsergame, you need a lobby.

The lobby is the place where people gather before a game starts. While in the lobby, people can change the gamemode, the game's settings, or anything else you implement for your lobby. The lobby is also the place for your global state, since it still exists when a game ends. Lobbies can be accessed via their ID, formatted as a hex string ({id:X}).

This example will simply start the "game" every time a player joins/leaves the lobby. The "game" will last one second and show the new number of players in the lobby.

These are the imports you will need for the example:

use std::time::Instant;
use ezbrowsergameserver::prelude::*;

First, create a struct for your global state:

struct GlobalState {
    player_count_changed: bool,
}

Then, one for your game:

struct PlayerCountGame(Option<Instant>);

Now, implement LobbyState for your GlobalState struct:

#[async_trait]
impl LobbyState for GlobalState {
    type PlayerState = ();
    fn new() -> Self {
        Self {
            player_count_changed: false,
        }
    }
    fn new_player() -> Self::PlayerState {
        ()
    }
    async fn player_joined(_id: usize, lobby: &mut Lobby<Self>, _player: PlayerIndex) {
        lobby.state.player_count_changed = true;
    }
    async fn player_leaving(_id: usize, lobby: &mut Lobby<Self>, _player: PlayerIndex) {
        lobby.state.player_count_changed = true;
    }
    async fn lobby_update(id: usize, lobby: &mut Lobby<Self>) -> Option<Box<dyn GameState<Self>>> {
        if lobby.reset {
            lobby.reset = false;
            // show lobby screen to all clients
            for (i, player) in lobby.players_mut().enumerate() {
                player
                    .send(format!(
                        "<h1>You are player #{}</h1><p>Lobby ID: {id:X}</p>",
                        i + 1
                    ))
                    .await;
            }
        }
        for player in lobby.players_mut() {
            if let Some(_) = player.get_msg().await {
                // if we don't try to get_msg, we don't detect player disconnects
            }
        }
        if lobby.state.player_count_changed {
            lobby.state.player_count_changed = false;
            // start the "game"
            return Some(Box::new(PlayerCountGame(None)));
        }
        None
    }
}

Then, implement GameState for your PlayerCountGame:

#[async_trait]
impl GameState<GlobalState> for PlayerCountGame {
    async fn update(&mut self, lobby: &mut Lobby<GlobalState>) -> bool {
        // game starts, update all clients
        if self.0.is_none() {
            self.0 = Some(Instant::now());
            let c = lobby.players().len();
            for player in lobby.players_mut() {
                player.send(format!("<h1>There are {c} players</h1>")).await;
            }
        }
        // game ends after 1 seconds
        self.0.is_some_and(|start| start.elapsed().as_secs() >= 1)
    }
    async fn player_leaving(&mut self, lobby: &mut Lobby<GlobalState>, _player: PlayerIndex) {
        lobby.state.player_count_changed = true;
    }
}

And finally, add your main function:

#[tokio::main]
async fn main() {
    host::<GlobalState>("0.0.0.0:8081").await;
}

That's it - we've created a "game".

If you want to try it out, go to the examples/ directory, run ./server.sh, run cargo run --example 00_min, then open 0.0.0.0:8080/00_min.html in a browser and create a new lobby.

QuickStart

cargo new my_project
cd my_project/
cargo add ezbrowsergameserver
cargo add tokio --features macros
echo 'use ezbrowsergameserver::prelude::*;

#[tokio::main]
async fn main() {
    host::<ToDo>("0.0.0.0:8081").await;
}' > src/main.rs

Feel free to experiment with the example files by copying them to src/main.rs and changing different things :)

Dependencies

~3.5–5.5MB
~96K SLoC