0
\$\begingroup\$

I'm building a game server for a turn-based RPG game, where the players control their characters through a web interface ( think kind of like a Jackbox Party Pack game, where there's a screen that shows shared info and the players devices let them send their inputs ).

I've got most of the foundation set up: clients can connect, create a game, additional players can join the game using a code, the game server is doing real basic game loop stuff.

I'm starting to work on the actual input & tick handling. Because this is in Go, there's a goroutine that handles the input which is separate from the goroutine that handles the game updating every tick. I've basically got a for loop with multiple selects ( so that a new client doesn't prevent a server tick from getting processed ), kind of like this:

func (g *Game )listen(){
    ticker := time.NewTicker(time.Millisecond * 200)

    for {
        select {
        case nc := <-g.newClients:
            g.acceptNewClient(nc)

        case lc := <-g.closingClients:
            g.unregisterClient(lc)

        default:
        }

        select {
        case u := <-g.output:
            if len(g.players) > 0 {
                for c := range g.players {
                    c.out <- u
                }
            }

        case in := <-g.input:
            g.handleInput(in)

        default:
        }

        select {
        case t := <-ticker.C:
            g.tick(t)

        default:
        }
    }
}

What I'm trying to figure out right now is the best way to handle that player input and how it should update the game state.

For example, the first state the game starts in is character select. All players are able to select one of four character classes, but can't double up ( ie, can't have two fighters -- first come first serve ). The VIP also has the option to start the game, which will randomly assign classes to players who haven't selected, and create AI players if there are fewer than 4 players.

Should the input handler be able to directly modify the game state -- ie, when it gets a message from player 1 saying they want to select a fighter, it modifies the game state to do the right thing, or should it be throwing messages into a queue? For example saying "hey, the VIP says to start the game" or "player 2 selected fighter" and then the tick handler does the appropriate thing?

\$\endgroup\$
4
\$\begingroup\$

so ... I can probably speak to your example, since I'm the primary author of the Jackbox Games multiplayer server, which as it turns out ... is written entirely in Go!

Since there's no simulation to run, there's no need for a tick since the game state does not update without user action. I do have a ticker for some other thing. You want to keep all of this in one select statement. Right now your ticker is blocking receiving input. You don't want to do that. If you want to batch all requests and then only process them at intervals, you'd still just use one big select.

The run loop that I use looks ... something like this:

// a Call is anything that update's the game's state.
// Calls are updated in series, so the Exec function does
// not need to be threadsafe, but the Exec function may not
// cause the room pointer to persist beyond its own invocation.
type Call interface {
    // Exec does NOT return an error. This is because an error ...
    // is expressed as a Response! If the server fails to process a
    // request, I have a response type that will render to the client
    // as an error message. Every request gets some sort of reply.
    Exec(*Room) Response
}

// a Request is a data structure from a client that has an Exec
// method that updates the game state.
type Request struct {
    // the client that sent the request
    From Conn

    // clients tag their requests with an ID; the client uses
    // this to match replies to their originating requests in
    // the client.
    ID int

    // call is polymorphic, it does the actual updating of the game state.
    Call
}

// a Response is any event that happens on the server.
type Response struct {
    // the Reply value is sent to the client that sent the
    // original request that caused this response to happen.
    Reply struct {
        Re int           // the ID of the request we're responding to
        Body interface{} // oversimplification lol
    }

    // the Notification value is sent to all of the other clients.
    // If an action doesn't notify other clients, this is nil.
    // I filter the messages before they go out but that's not shown here.
    Notification interface{}
}

// Room is the state of our game. There's a lot more here in
// the prod implementation and this is just an abbreviation.
type Room struct {
    // clients all have a reference to this channel. They send their
    // requests onto this channel.
    inbox chan Request

    // the room has a table of connected clients.
    clients map[int]Conn

    // there's a container object that contains the
    // entities in the game. It doesn't actually look
    // like this in prod :D
    entities map[string]interface{}
}

// the run loop just reads off of a channel and processes requests one at a time.
func (r *Room) run() {
    t := time.NewTicker(d)
    for pc := 0; true; pc++ {
        select {
        case req := <-r.inbox:
            res := c.Exec(r)
            for id, conn := range r.clients {
                if conn == req.From {
                    conn.send(pc, req.ID, res.Reply)
                } else {
                    conn.send(pc, 0, res.Notification)
                }
            }
        case <-t.C:
            // do something periodic here. Maybe your inbox
            // changes are actually put into a buffer and you
            // apply the buffer here if you want to simulate
            // turns. I use a periodic ticker for reporting observability
            // metrics here.
        }
    }
}

This is an oversimplification, but essentially what I do is:

  • every client has a read goroutine and a write goroutine
  • every room has just one run goroutine
  • every client has a reference to the room's inbox channel (but not its state otherwise) in its read goroutine
  • the clients' read goroutines read messages off of their respective websocket, parse them, and turns them into a request object
  • the request object goes onto the room's inbox queue
  • the room applies the requests one at a time
  • the room enqueues messages with the client, and the client's write goroutine actually sends them. The send method in the example wraps a statement that will disconnect the client if they're not actually reading their messages.
  • some surprising things like the client initially joining the room are requests. I had a separate join channel for a long time but it didn't actually do anything, it just made the code more tedious. The request has to be polymorphic anyway; updating the table of connected clients is updating the game state, it doesn't actually need a special case.

It's actually pretty simple, it just takes a few wrapper objects to make it all work. It's really just using Go's standard concurrency facilities and the command pattern to put commands in a queue. I also have things like another channel of calls that's not from users to do admin things, other tickers to do other periodic work, much more complex logic to do our audience functionality, etc, etc, etc. But this is the core of it: the i/o is highly multithreaded, but the actual game state updates are single-threaded.

| improve this answer | |
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.