Go gRPC Streaming Services & Unity Game Client
At the forefront of web service development, the digital entertainment sector stands as a critical area, not only as a prominent market for the commercialization of products and services but also due to its demand for high availability and performance to satisfy the growing consumption of users. In this context, the realm of “Gaming” emerges as an essential category that deserves to be explored in detail. Therefore, this article focuses on the crossroads between technology and entertainment, addressing the integration of backend services developed in Go, a high-performance programming language, with Unity, one of the most recognized and widely used game engines in the current industry. Our approach is directed towards the implementation of bidirectional communication flows through gRPC streams, establishing an efficient and robust bridge between Go services and Unity clients.
The concept we aim to materialize is the development of an independent game in Unity featuring a simple geometric shape, such as a square or cube, that moves in multiple directions. The key to our implementation lies in capturing and transmitting the XYZ coordinates of the moving object via an RPC method to our service A. Once the information is received, service A will calculate and replicate the object’s movement or position in an environment that simulates real-time through bidirectional streams.
This process will allow for fluid and coherent interaction between different game objects and services operating within the same environment or level, thus ensuring a continuous and dynamic gaming experience. What follows is, probably the simplest and most graphic representation of what has been explained above:
To bring this project to life, we will leverage a set of technologies that work in harmony to provide the necessary functionalities. Below is a detailed description of each component of the stack and its role in the construction of our bidirectional communication system.
Stack
- Websocket: A communication protocol that provides full-duplex, bidirectional communication channels over a single TCP connection, which is crucial for applications requiring real-time interaction, such as online games, as it reduces latency compared to other models.
- gin + gorilla/websocket: Gin is a high-performance web framework written in Go that facilitates the creation of robust and efficient web applications. It provides a set of tools for handling requests, routes, and middleware with simple and efficient syntax. We will combine this with the gorilla/websocket library, a Go implementation of the WebSocket protocol, which enables real-time communication between the client and the server.
- Protocol Buffers: A data serialization system developed by Google that offers an efficient and automated mechanism for serializing structured data, such as configurations, communication protocols between services, and data storage. In our context, we will use it to define the RPC messages and services that will communicate between the Unity-developed client and our Go-based services.
- Golang: Go is a modern programming language designed to be simple, fast, and efficient. It is particularly well-suited for building distributed systems, microservices, and backend services due to its performance and ease of handling concurrency (this could be kept in mind for future developments or improved versions of the current post).
- Unity: A powerful game engine and development platform that enables creators to design interactive experiences in 2D, 3D, virtual reality (VR), and augmented reality (AR). It is popular for its flexibility and ability to deploy games across multiple platforms. We will use Unity to develop the game’s interface and client-side logic.
- C#: An object-oriented programming language developed by Microsoft as part of its .NET platform. In our project, it is used as the main scripting language in Unity to write game logic and handle communication with the backend through gRPC and WebSockets.
The meticulous integration of these technologies is crucial for the seamless operation of our Unity game. By masterfully combining them, we not only establish a robust channel for real-time communications, which is indispensable for an interactive and smooth gaming experience, but we also build a system capable of scaling to accommodate an increasing number of users without compromising performance.
Furthermore, our architecture's modular and well-defined nature ensures that future expansions, such as the addition of new features or integration with other services, can be carried out with ease. In this way, each tool not only fulfills its specific function but also contributes to a solid and flexible foundation, prepared to adapt to the changes and challenges that the future may hold in the dynamic world of video games.
Demo
Before diving into coding, it is essential to establish a clear directory structure to organize our files in a logical and accessible manner. Therefore, below is a description of the folders that will form the basis of our project architecture:
- build: This directory will house an automated script capable of generating Go code based on the definitions contained in our .proto file. This script will simplify the process of updating our code whenever we modify the Protocol Buffers specifications.
- client: Here, all the client logic associated with
service-A
will be located, which is the connection point for our Unity interface through WebSockets. This client will be responsible for establishing and maintaining real-time communication with the server and managing incoming and outgoing data. - server: In this directory, the logic for
service-B
will be developed, which is crucial as it will handle processing the received coordinates and calculating the position of our geometric figure in the predefined game scene. It is important to emphasize that the precision and efficiency of this service are fundamental for synchronization and the user experience in the game. - protos: This will contain the
service.proto
file, which is the heart of our inter-service communication. Here, we will define the structure of the messages and the RPC (Remote Procedure Call) methods that will facilitate the exchange of data between different components of our application. Keeping this file in a dedicated directory helps to centralize definitions and makes maintenance and access easier.
Organizing our project in this way ensures that each component has its specific place, making the development process more intuitive and the long-term maintenance of the code simpler. With this structure established, we are ready to move forward with the implementation of the necessary logic to facilitate bidirectional communication between our backend services.
Before diving into the generation of Go code for our services, we must install the necessary plugins for the Protocol Buffers compiler. It’s important to note that these plugins will allow us to transform our .proto
definitions into Go code that can be used for both gRPC and standard Protocol Buffers serialization.
# Install Go plugins for Protocol Buffers and gRPC.
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
# Update our PATH so `protoc` can find the plugins.
export PATH="$PATH:$(go env GOPATH)/bin"
With these simple commands, we ensure that the protoc-gen-go
and protoc-gen-go-grpc
plugins are installed and accessible in our environment, which prepares us for the next phase of our development workflow.
Before we begin coding, it is crucial to establish the foundations of our inter-service communication by outlining the contracts and messages that will be exchanged between the services involved in the project. These contracts are embodied in .proto
files, which define the data structures and RPC methods. The specified RPC methods will be responsible for the remote invocation of operations, such as calculating and synchronizing the position of the object reflected in the game. These definitions act as a shared agreement that ensures coherent and effective communication.
Next, we will detail the structure of these messages and methods, which are the cornerstone of our data and coordinate flow.
syntax = "proto3";
// Package declaration for the server package.
package server;
// Go package option for code generation.
option go_package = "github.com/Jhooomn/bidirectional-stream-comunication/protos";
// CalculatorService is a gRPC service that provides a method for performing calculations.
service CalculatorService {
// Calculate is a bidirectional streaming RPC method.
// It takes a stream of CalculateRequest messages as input
// and returns a stream of CalculateResponse messages as output.
rpc Calculate (stream CalculateRequest) returns (stream CalculateResponse) {}
}
// CalculateRequest is a message that represents the input values for a calculation.
message CalculateRequest {
// The X coordinate input value.
double x = 1;
// The Y coordinate input value.
double y = 2;
// The Z coordinate input value.
double z = 3;
}
// CalculateResponse is a message that represents the output values of a calculation.
message CalculateResponse {
// The X coordinate output value.
double x = 1;
// The Y coordinate output value.
double y = 2;
// The Z coordinate output value.
double z = 3;
}
We will place this file in the root folder with the following structure ./proto/service.proto
. Following that, we will execute the command below, which will allow us to generate the files and Go code to manage the method and endpoint generated, named Calculate
, under the CalculatorService
service.
./scripts/generate_protobuf.sh
Within this file, we will be able to specify the following information, which will enable us to generate the Go code that translates our previously mentioned ./proto/service.proto
file.
#!/bin/bash
# This bash scripts is used to generate Go code from Protocol Buffers (.proto files) for both the server and the client components of our application.
# Generate Go code for server
# The protoc command is called with the --go_out flag to specify the output directory for the generated Go code,
# which will contain the standard Protocol Buffer message code.
# The --go_opt=paths=source_relative option configures the output to be relative to the module's source directory.
# The --go-grpc_out flag specifies the output directory for the generated gRPC code,
# and the --go-grpc_opt=paths=source_relative option similarly sets the gRPC output to be module source relative.
# The path to the service.proto file is provided as the input file for the protoc compiler.
protoc --go_out=./servr --go_opt=paths=source_relative \
--go-grpc_out=./servr --go-grpc_opt=paths=source_relative \
protos/service.proto
# Generate Go code for client
# This block does essentially the same as the above, but the output directory is set to the client folder,
# ensuring that both the server and the client have their own generated code based on the same .proto definitions.
protoc --go_out=./client --go_opt=paths=source_relative \
--go-grpc_out=./client --go-grpc_opt=paths=source_relative \
protos/service.proto
# This echo command outputs a confirmation message to the terminal once the code generation is complete.
echo "Protobuf files generated."
The goal is that, after executing the previously mentioned script, the message “Protobuf files generated” will be displayed in the output. Upon revisiting the directory where the files are stored, we will notice the creation of two new resources: service.pb.go
and service_grpc.pb.go
. These files represent the Go code automatically generated from the Protobuf specification contained in ./proto/service.proto
. This code facilitates the use of the Calculate
function through the CalculatorServiceClient
. Additionally, the Protobuf messages are converted into Go structures, such as CalculateResponse
and CalculateRequest
, allowing them to be managed within the Go environment.
Service B (Server)
Considering the previously described service schema, we will develop a dedicated service to calculate the coordinates that our continuously streaming object will receive. This calculation will enable the object to move through the scene based on the results obtained from the mirror object (the main player controlled by the user). This service will be named service-b
or, more simply, server
. Below, we can observe what might be the main.go
file with the aforementioned features already implemented.
package main
import (
"io"
"log"
"net"
"github.com/Jhooomn/bidirectional-stream-comunication/servr/protos"
"google.golang.org/grpc"
)
// server implements the CalculatorServiceServer interface.
type server struct {
protos.UnimplementedCalculatorServiceServer
}
const (
xIncrement = 2
yIncrement = 0
zIncrement = 0
)
// Calculate processes the stream of CalculateRequest messages and responds with
// CalculateResponse messages that contain updated coordinates. It applies a fixed
// increment to the X, Y, and Z values of the request and sends back the result.
func (s *server) Calculate(stream protos.CalculatorService_CalculateServer) error {
for {
// Receive a message from the stream.
req, err := stream.Recv()
if err == io.EOF {
// End of stream.
return nil
}
if err != nil {
log.Printf("error receiving from stream: %v", err)
return err
}
// Create a response with the updated coordinates.
resp := &protos.CalculateResponse{
X: req.GetX() + xIncrement,
Y: req.GetY() + yIncrement,
Z: req.GetZ() + zIncrement,
}
// Send the response back to the client.
if err := stream.Send(resp); err != nil {
log.Printf("error sending to stream: %v", err)
return err
}
}
}
// main sets up the gRPC server and listens for incoming connections on port 50051.
// It registers the server as a CalculatorServiceServer to handle incoming RPC calls.
func main() {
// Listen for TCP connections on port 50051.
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
// Create a new gRPC server instance.
s := grpc.NewServer()
// Register the server with the gRPC server to handle CalculatorService RPCs.
protos.RegisterCalculatorServiceServer(s, &server{})
// Start serving incoming connections.
if err := s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}
As can be seen, we have configured our gRPC service/server to listen on port :50051
, which allows for the execution of all types of operations on our endpoint or Calculate
method. In addition, we have established certain constants that will facilitate the addition of a specific number of spaces or units (distance in Unity) so that our mirror object can be positioned in a different location from the main player. For this example, we want the mirror object to be situated 2 units away on the X-axis (to the right) of the main player.
Service A (Client)
An additional layer is required to establish and maintain a WebSocket connection to invoke the method located on the server side. This layer will be responsible for facilitating interaction with our service-B
. Below are some key functions needed for this purpose:
- Establish and allow a WebSocket connection between the Unity client and this service.
- Maintain the gRPC connection with service-B to perform the necessary calculations and receive the response with the new position of our continuously streaming object.
- Keep an active listening for incoming events through the WebSocket channel established with our Unity client.
- Log events, including new requests and responses issued by the server, for effective tracking.
- Provide a health-check endpoint to confirm the availability and operational status of our service-A.
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/Jhooomn/bidirectional-stream-comunication/client/protos"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"google.golang.org/grpc"
)
const (
// readBufferSize is the size of the buffer used to read messages from the WebSocket connection.
readBufferSize = 1024
// writeBufferSize is the size of the buffer used to write messages to the WebSocket connection.
writeBufferSize = 1024
)
var (
// streamStore holds the active gRPC streams for each worker.
streamStore = make(map[string]protos.CalculatorService_CalculateClient)
// stream is the current gRPC stream for bidirectional communication.
stream protos.CalculatorService_CalculateClient
)
// CalculateRequestPayload defines the structure for the JSON payload
// for incoming calculation requests.
type CalculateRequestPayload struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Z float64 `json:"z"`
}
// CalculateResponsePayload defines the structure for the JSON payload
// for outgoing calculation responses.
type CalculateResponsePayload struct {
X float64 `json:"x"`
Y float64 `json:"y"`
Z float64 `json:"z"`
}
// WebSocket upgrader which will take care of the handshake process
var wsupgrader = websocket.Upgrader{
ReadBufferSize: readBufferSize,
WriteBufferSize: writeBufferSize,
// Custom check function to allow connections from all origins (for development)
// Note: In production, you should specify allowed origins for better security.
CheckOrigin: func(r *http.Request) bool {
return true
},
}
// main initializes the server and sets up the routes.
func main() {
setUp()
}
// setUp initializes the Gin router and defines the routes.
func setUp() {
r := gin.Default()
// Health check endpoint to verify server status.
r.GET("/", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "I'm alive",
})
})
// WebSocket endpoint for handling calculation requests.
r.GET("/ws", calculateWsHandler)
// Start the server and listen on port 8080.
panic(r.Run()) // listen and serve on 0.0.0.0:8080
}
// calculateWsHandler handles incoming WebSocket connections and sets up
// the gRPC stream for bidirectional communication with the calculation service.
func calculateWsHandler(ctx *gin.Context) {
var err error
// Upgrade initial GET request to a WebSocket protocol.
wsConn, err := wsupgrader.Upgrade(ctx.Writer, ctx.Request, nil)
if err != nil {
http.Error(ctx.Writer, "Could not open WebSocket connection", http.StatusInternalServerError)
return
}
workerID := "1" // TODO: get it from client
// Check if a stream already exists for the worker, otherwise create one.
if s, found := streamStore[workerID]; !found {
// Set up a gRPC connection to the service-B.
conn, err := grpc.Dial("localhost:50051", grpc.WithInsecure(), grpc.WithBlock())
if err != nil {
log.Fatalf("Did not connect: %v", err)
}
cClient := protos.NewCalculatorServiceClient(conn)
// Create a client stream for bidirectional communication.
stream, err = cClient.Calculate(ctx)
if err != nil {
log.Fatalf("Error creating stream: %v", err)
}
streamStore[workerID] = stream
} else {
stream = s
}
// Ensure cleanup of resources when the handler exits.
defer func(workerID string) {
wsConn.Close() // Close the WebSocket connection.
stream.CloseSend() // Close the send direction of the stream.
delete(streamStore, workerID) // Remove the stream from the store.
fmt.Println(fmt.Sprintf("WebSocket and stream channel for workerID: [%s] have been closed", workerID))
}(workerID)
// Listen for messages from the Unity client WebSocket connection.
for {
// Read a message from the WebSocket connection.
msgType, msg, err := wsConn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
http.Error(ctx.Writer, "Unexpected WebSocket error connection", http.StatusBadRequest)
}
break
}
// Log the received message.
log.Println(string(msg))
var payload *CalculateRequestPayload
// Deserialize the JSON payload into a CalculateRequestPayload struct.
err = json.Unmarshal(msg, &payload)
if err != nil {
http.Error(ctx.Writer, "Couldn't read the message", http.StatusBadRequest)
break
}
// Create a gRPC request from the payload.
req := &protos.CalculateRequest{
X: payload.X,
Y: payload.Y,
Z: payload.Z,
}
// Send the request over the gRPC stream.
err = stream.Send(req)
if err != nil {
http.Error(ctx.Writer, fmt.Sprintf("Error sending request: %v", err), http.StatusBadRequest)
break
}
// Receive the response from the gRPC stream.
resp, err := stream.Recv()
if err != nil {
http.Error(ctx.Writer, fmt.Sprintf("Error receiving response: %v", err), http.StatusBadRequest)
break
}
// Log the calculated coordinates.
log.Printf("X: %v, Y: %v, Z: %v", resp.GetX(), resp.GetY(), resp.GetZ())
// Prepare the response payload.
msgback := &CalculateResponsePayload{
X: resp.X,
Y: resp.Y,
Z: resp.Z,
}
// Serialize the response payload to JSON.
marshal, err := json.Marshal(msgback)
if err != nil {
http.Error(ctx.Writer, fmt.Sprintf("Could not create response back: %v", err), http.StatusInternalServerError)
break
}
// Write the JSON response back to the WebSocket connection.
if err := wsConn.WriteMessage(msgType, marshal); err != nil {
http.Error(ctx.Writer, "Unexpected WebSocket error connection while writing message", http.StatusBadRequest)
break
}
}
}
The previously provided file includes a detailed definition, supplemented with explanatory comments, of all the specifications implemented; these details are crucial for establishing effective bidirectional communication between our client and the server, using streams and WebSockets. With that said, we will demonstrate through the various logs that can be seen below that if we deploy our service-B
and service-A
locally, the latter will appear as follows in our console.
Unity Game Client
Having finished practically all the necessary configuration on the backend side to perform the various operations and interactions required to calculate the new position of our object in a continuous flow, we will proceed to create a simple project in Unity, this will have some geometric shapes that will represent our main player, a base or area of movement, and a second object that we will call stream object, the latter will receive the updated coordinates to reflect its new position within the scene.
To carry out this development, after installing the Unity game engine, we will open Unity Hub. This tool facilitates the management of several versions of the engine and the creation of new projects. We will start by selecting the “New Project” option, then choosing the “3D” template for the project type. We will assign a name of our preference to the project, and we will click on “Create” to start.
The initialization of the project in Unity may take some time, as the program will download a number of resources that had not been previously acquired to configure the project properly. After this process, an empty scene will be presented where we can place our geometric figures or GameObjects
(a term that we will use from here on), which will serve as visual representations of these elements in the scene.
In our initial scene, we will create a plane and two cubes (assigning them different names and colors). We will position the camera statically to ensure the scene's visibility and thus be able to demonstrate the movement between these components; then, in our file manager, we will create a folder containing two scripts in C#. We will name these scripts as follows:
- InitialGameScript
- PlayerController
InitialGameScript
will be in charge of establishing the communication between our client/game in Unity and our service that provides the WebSocket (WS) communication channel. In the following code block, we show how we initially declare an object of type WebSocket that will assume this functionality, in addition, we define two objects of type GameObject
that will reference both cubes present in the scene; the SerializeField
decorator allows us to interact directly from the Unity editor to assign these references using the drag and drop technique. In this way, we can configure our objects to behave according to what we define in this script. We set the webSocketUrl
variable with which we will work (the endpoint), and we use a data structure defined as Queue
type to handle the responses received.
using UnityEngine;
using WebSocketSharp;
using System.Collections.Generic;
public class InitialGameScript : MonoBehaviour
{
WebSocket ws;
[SerializeField] private GameObject playerObject;
[SerializeField] private GameObject streamObject;
[SerializeField] private string webSocketUrl = "ws://localhost:8080/ws";
private Queue<CalculateResponsePayload> responseQueue = new Queue<CalculateResponsePayload>();
private void Start()
{
ws = new WebSocket(webSocketUrl);
ws.OnMessage += (sender, e) =>
{
string message = e.Data;
Debug.Log("Message received from ws: " + message);
CalculateResponsePayload response = JsonUtility.FromJson<CalculateResponsePayload>(message);
lock (responseQueue) // lock to ensure thread-safe access to the queue
{
responseQueue.Enqueue(response); // Enqueue the response to be handled on the main thread
}
};
ws.Connect();
}
private void Update()
{
// Send position of the playerObject
if (ws.ReadyState == WebSocketState.Open && playerObject != null)
{
SendTargetObjectPosition(playerObject.transform.position);
}
// Apply received position updates from the WebSocket server to the streamObject
if (responseQueue.Count > 0)
{
lock (responseQueue) // Again, lock for thread-safe access
{
while (responseQueue.Count > 0)
{
var response = responseQueue.Dequeue(); // Dequeue the next response
if (streamObject != null)
{
streamObject.transform.position = new Vector3(response.x, response.y, response.z);
}
}
}
}
}
private void SendTargetObjectPosition(Vector3 position)
{
var payload = new CalculateRequestPayload
{
x = position.x,
y = position.y,
z = position.z
};
string jsonPayload = JsonUtility.ToJson(payload);
ws.Send(jsonPayload);
}
private void OnDestroy()
{
if (ws != null)
{
ws.Close();
ws = null;
}
}
}
[System.Serializable]
public class CalculateRequestPayload
{
public float x;
public float y;
public float z;
}
[System.Serializable]
public class CalculateResponsePayload
{
public float x;
public float y;
public float z;
}
In our script, we can observe two fundamental functions at the beginning: Start
and Update
, as can be deduced, the Start
function will execute all the instructions contained in this block only once, either at the beginning of the scene or when this script is invoked. This is where we configure or instantiate our WebSocket object, thus establishing communication with our backend service and through a message of type Debug.Log
, we confirm that the communication has been successfully established and we start to accumulate in our responseQueue
the information received from the server, effectively performing a Subscribe
to this data intercommunication channel.
The Update
function is executed iteratively since it is invoked in each frame that passes in the game. Within this function, we verify that our communication channel is available to send the current position of our player (playerObject
), in addition, we check if our responseQueue
contains data, if so, we extract the most recent response of this structure and update the XYZ position of our streamObject
; the CalculateRequestPayload
and CalculateResponsePayload
structures are fundamental to serialize and deserialize the request and response that arise from this interaction between services.
In this script, we are going to attach it together with an empty game object that we can locate in any part of the scene that we are working on at the moment. To the right of this, we will click on the option Add Component, and we will look for this element as it is seen in the previous image. In the upper part, we must drag or assign our Player (or player 1 in my scene) and our Stream object in the corresponding spaces. Additionally, if we want to configure, we can modify the URL that is pre-configured, and if we edit this value, it will be reflected in the behavior of the project.
PlayerController
is a script that will allow to define the behavior of our player (I have added the variable isPlayer
because I used the same cube object for both players) and to move it from the keys of our keyboard (WASD
), the code that we see next is inferable of its behavior and unlike the previous one, it doesn't have the Start
function.
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class PlayerController : MonoBehaviour
{
[SerializeField] private bool isPlayer;
[SerializeField] private float moveSpeed = 5f;
void Update()
{
if (isPlayer)
{
float moveHorizontal = Input.GetAxis("Horizontal");
float moveVertical = Input.GetAxis("Vertical");
Vector3 movement = new Vector3(moveHorizontal, 0.0f, moveVertical);
transform.position += movement * moveSpeed * Time.deltaTime;
}
}
}
This script can be attached to our prefab and assigned to the component created in question, and in our scene, we can establish from our boolean
field which of the two components will be the player controlled by our inputs via keyboard and which would not react to these instructions.
After having made all these configurations, we can validate and test our project. In the upper central part of the Unity editor, we will find a Play button, and when pressing it, our Unity client or game will start. It is important to make sure that our backend services for the calculation of coordinates are running locally or are deployed in a cloud provider because by doing so we can see in our logs each of the positions or new coordinates that are generated from the calculations made in our backend services.
Conclusion
In this way, we achieve a complete integration in a very basic way using novel resources such as Unity for the development of video games. The interaction in real-time can be observed reactively in our client, allowing analytics, behavioral observation, and decision-making, which are fundamental elements of this interaction. The idea is to continue complementing this project in future publications to go even deeper into this interesting world of the gaming sector with the participation of tools developed under the programming language: Go.