The vanilla UNO itself is a simple game to implement. Initially I thought of doing a completely command-line based interface, but that gets very unwieldy. So I quickly switched to termui.
Being built for dashboards, the UI library itself isn’t the most glamorous and I am contemplating moving to bubbletea. But that has to wait.
In this post, I will note down a few of the stuff I have learned while implementing this prototype.
Typed channels
The UI is decoupled from the player client code itself and all communication happens via some channels. Since you can statically type your data in Go, you can have multiple channels like
clientChannels := client.ClientChannels{
GeneralUICommandPushChan: commChannels.GeneralUICommandChan,
AskUserForDecisionPushChan: commChannels.AskUIForUserTurnChan,
NonDecisionReplCommandPullChan: commChannels.NonDecisionReplCommandsChan,
LogWindowPushChan: commChannels.LogWindowChan,
GameEventPushChan: commChannels.GameEventChan,
}
and immediately know what kind of data each of them are supposed to carry
(unless you use interface{}
). Lookin at you, Elixir. The above shows all the
channels that the client code uses to communicate with the UI.
In big production apps and microservices, you have stuff like message-queues and
pub-sub based frameworks. Go channels are like message queues in a single
process… so… just threadsafe queues that are first class citizens of the
language (since you can wait on them via select
).
Player client
The admin program, right now, is really acting as a synchronization point for each player decision. Admin chooses a player and asks for that players turn → Player makes a turn (so-called “decision”) and sends to admin → Admin runs decision on its own table object and also sends the decision to all other player clients → All player clients run the decision on their own table object and wait for admin to choose the next player. Note that since all clients already has the entire state of the uno table, they already “know” which player’s turn is next, or if a player just won the game. But they still wait for admin to announce it - because the admin is the “leader” - shades of consensus protocols come to mind, but I ain’t crazy enough to put in a raft module in this. Or perhaps it will be interesting to play around with an admin-less version.
Server-sent events
The admin needs to push messages to the client. A dedicate socket connection is
straightforward, but I had made the mistake of actually running an http.Server
in the client itself that the admin can make requests to. Noob move. Now all
players would need to make their computers accessible to the internet somehow -
not something you can expect from clients with any sort of sanity.
One decent way to have the admin maintain an open connection to the client is
via HTTP/1’s chunked response encoding. It really just amounts to having
keep-alive
, no-cache
headers on the http request (and response). Rest of the
stuff like “sending size value followed by two carriage-returns” is handled by
http.Flusher
so you don’t really need to care about the exact format of the
chunked-response format. In HTTP/1 it will also add transfer-encoding: chunked
header on the first payload. In fact, if you’re using HTTP/2, chunked encoding
is not supported by the protcol, but http.Flusher
will handle sending messages
transparently using message frames.
When the client makes a request like POST /player
, that HTTP connection
becomes a long lived one that we can use io.Read
on in a loop to get a
constant stream of bytes. Of course you need to delimit your individual messages
to be able to discern each individual message. I use line-delimited JSON
messages (which means you cannot have “’\n’” character appearing in the JSON
strings - works ok for my use-case).
There is however an issue with having long-lived HTTP connection like this. If
you set any read timeouts on the http.Client
that you for reading the stream,
it may likely fail since it’s meant to be writing to the connection (i.e. not
closing the connecting) for a relatively long time compared to your usual REST
API calls. So it’s best to use a separate client object for this with a differnt
configuration.
Go’s default http request handling
While http.Flusher
takes care of flushing individual messages, a not so good
of a news is that if you want to do this from a single “controller” goroutine
which manages multiple such http.ResponseWriter
s and corresponding
http.Flusher
s for multiple clients, you would need to keep the original
handler invocation for each of the clients “alive”, by which I mean prevent
returning from the handler function call because the http.Server
will
close (or mark the connection as idle for pooling) once the handler returns.
Now, http.Hijacker
will prevent that but once you use that you will have to
work with a TCP net.Conn
, implementing the chunked protocol yourself.
So that leaves me to think that the “best” way is to prevent returning from the handler and keeping it “alive” for the duration of the controller goroutine.
More to come
I’m kinda excited with this project since it serves as a test-bench of sorts for me - a mental code-gym of sorts. Will be updating here when I make the next step. In particular I have to write a proper server that is capable of handling multiple game-sessions. Bit of a chore, but absolutely crucial for an MVP.