Real-Time Gameplay Setup
This article will walk you through setting up your game for real-time gameplay on Skillz. Make sure you have implemented the Skillz Delegate before proceeding. We recommend reviewing the documentation for Standard Gameplay in order to understand the basics of the Skillz SDK integration.
Skillz Technologyβ
Skillz synchronous technology defines a real-time game as a multiplayer game where two or more players interact in real time. Most online games fall into this category, and can be thought of as an action by one player affects another playerβs gamestate. In this overview, we will briefly describe the high level architecture on how we support multiplayer games on the Skillz platform.
Overviewβ
Skillz synchronous technology is based on a server authoritative model. That is, an authoritative game server owns the game state to help protect against unauthorized operations by a client (e.g. cheating). Skillz hosts the servers on a scalable cloud hosting solution which you have the option to customize based upon your games needs.
To support a synchronous game server on the Skillz platform, Skillz requires the following:
- A game server must be containerized (e.g. Dockerized) for deployment.
- The game server must support TCP/IP.
- A game server must have a 1:1 relationship between game match (or game room) and a client.
- A game server must be able to service multiple clients simultaneously on a single port / IP that Skillz will assign.
- We currently use AWS Amazon Elastic Compute Cloud (or EC2) instances and Kubernetes (or K8s) to host our platform.
- We can only support Linux based game servers. Windows Hosting is currently not currently supported although we have plans to support it in the future.
Architecture Diagramβ
The diagram below provides an overview of of the key components:
Developer Workflowβ
- Build your Real-Time Gameplay server
- Integrate the Skillz SDK
- Test your game connectivity with your server running locally by hard coding the connection received client-side from the Skillz SDK
- Please reference the Testing Real-Time Games section for common use cases to test against
- Once you have verified things work locally, work with the Skillz team to spin up a Sandbox server to validate the workflow.
- Once Sandbox testing is complete, Skillz will work with you to rollout the game into production.
Creating a Custom Real-Time Gameplay Serverβ
In this section we are going to learn how to deploy the skillz-example-sync-server project locally.
NOTE: Click here to learn more about the Example Sync Server and the Skillz Server SDK.
Please note that the complete implementation of server-side gameplay logic is up to the developer. The Skillz Example Sync Server, as the name implies, only provides sample code to handle common gameplay logic.
In the Reporting Scores section, you will learn how to implement logic to report match scores back to Skillz from the server.
Install Javaβ
If you are developing and testing your code locally, you will need to install Java.
NOTE: There are many implementations of Java, so pick the one that suits you best.
In the Example Sync Server, we use Amazon Corretto 11, which we will use in this guide.
To install Amazon Corretto 11, follow the instructions below:
- Amazon Corretto 11 Installation Instructions for Windows 7 or Later
- Amazon Corretto 11 Installation Instructions for macOS 10.13 or later
- Amazon Corretto 11 Installation Instructions for Debian-Based, RPM-Based and Alpine Linux Distributions
Install Dockerβ
Since the Gameplay Server needs to be submitted to Skillz as a Docker Image, you will need to install Docker.
To do so, follow the instructions below:
Running the Example Sync Server Locallyβ
Now that we have Java and Docker installed, let's try to get the Sample Sync Server running locally.
To do so, run the instructions below:
# Clone the example sync server
git clone https://github.com/skillz/skillz-example-sync-server.git
# Go to directory
cd skillz-example-sync-server
# Compile the gameplay server code
./gradlew build
# Start the gameplay server
./gradlew run
If you get logs similar to the following, that means the sample server was started successfully:
...
> Task :example_sync_server:run
2022-02-17 | 10:18:06.883 | main | INFO | com.skillz.server.Server | Running with INFO level logging.
2022-02-17 | 10:18:06.910 | main | INFO | com.skillz.server.Server | Starting Skillz Server 1.3.3...
2022-02-17 | 10:18:08.050 | main | INFO | com.skillz.server.World | Loading ExampleSkillzGame 1.0...
2022-02-17 | 10:18:08.071 | main | INFO | com.skillz.server.World | Using custom TICK_RATE of 100ms
2022-02-17 | 10:18:08.073 | main | INFO | com.skillz.server.World | Using custom WARNING_SECONDS value of 4
2022-02-17 | 10:18:08.073 | main | INFO | com.skillz.server.World | Using custom DISCONNECT_SECONDS value of 15
2022-02-17 | 10:18:08.073 | main | INFO | com.skillz.server.World | Using custom MAX_RECONNECTS value of -1
2022-02-17 | 10:18:08.073 | main | INFO | com.skillz.server.World | Using custom MAX_ALLOWED_APP_PAUSES value of -1
2022-02-17 | 10:18:08.073 | main | INFO | com.skillz.server.World | Using custom MIN_TIME_IN_SECONDS_FOR_NEW_PAUSE value of 1
2022-02-17 | 10:18:08.073 | main | INFO | com.skillz.server.World | Using custom MAX_ALLOWED_APP_CONNECTION_WARNINGS value of -1
2022-02-17 | 10:18:08.076 | main | INFO | com.skillz.server.World | Using custom USE_CUMULATIVE_PAUSE_DISCONNECT_TIMER value of true
2022-02-17 | 10:18:09.220 | main | INFO | c.s.server.ContentLoader | Loaded 2 MessageHandlers
2022-02-17 | 10:18:09.362 | main | INFO | com.skillz.server.Server | Server started successfully on port 10140!
<==========---> 80% EXECUTING [1m 4s]
> :example_sync_server:run
To end the server process, press CTRL + C
.
Running the Example Sync Server on Dockerβ
Now that you know how to run the sync-server locally, let's try running it from a Docker container with the following commands:
# Clone the example sync server
git clone https://github.com/skillz/skillz-example-sync-server.git
# Go to directory
cd skillz-example-sync-server
# Compile the gameplay server code
./gradlew jar
# Go to app directory
cd example_sync_server
# Create Docker Directory
mkdir -p build/docker/resources
# Go to Docker Directory
cd build/docker
# Copy all files needed for sync-server to run
cp ../libs/*.jar ./server.jar
cp -rf ../../resources/certs resources
cp ../../../{Dockerfile,healthcheck.sh} .
# Build the Docker Image
docker build --platform linux/amd64 -t "sync-server:latest" .
# Start the Sync Server Container
docker run --name sync-server -e SYNC_RELEASE_CONFIGURATION="info" -p 10140:10140 -d "sync-server:latest"
# Get logs from Docker
docker logs -f sync-server
If you get logs similar to the following, that means the sample server was started in Docker successfully.
2022-03-03 | 19:45:53.838 | main | INFO | com.skillz.server.Server | Running with INFO level logging.
2022-03-03 | 19:45:53.855 | main | INFO | com.skillz.server.Server | Starting Skillz Server 1.3.4...
2022-03-03 | 19:45:54.959 | main | INFO | com.skillz.server.World | Loading ExampleSkillzGame 1.0...
2022-03-03 | 19:45:54.978 | main | INFO | com.skillz.server.World | Using custom TICK_RATE of 100ms
2022-03-03 | 19:45:54.979 | main | INFO | com.skillz.server.World | Using custom WARNING_SECONDS value of 4
2022-03-03 | 19:45:54.979 | main | INFO | com.skillz.server.World | Using custom DISCONNECT_SECONDS value of 15
2022-03-03 | 19:45:54.979 | main | INFO | com.skillz.server.World | Using custom MAX_RECONNECTS value of -1
2022-03-03 | 19:45:54.979 | main | INFO | com.skillz.server.World | Using custom MAX_ALLOWED_APP_PAUSES value of -1
2022-03-03 | 19:45:54.980 | main | INFO | com.skillz.server.World | Using custom MIN_TIME_IN_SECONDS_FOR_NEW_PAUSE value of 1
2022-03-03 | 19:45:54.980 | main | INFO | com.skillz.server.World | Using custom MAX_ALLOWED_APP_CONNECTION_WARNINGS value of -1
2022-03-03 | 19:45:54.982 | main | INFO | com.skillz.server.World | Using custom USE_CUMULATIVE_PAUSE_DISCONNECT_TIMER value of true
2022-03-03 | 19:45:55.062 | main | INFO | c.s.server.ContentLoader | Loaded 1 MessageHandlers
2022-03-03 | 19:45:55.120 | main | INFO | com.skillz.server.Server | Server started successfully on port 10140!
To end the server process, run the following commands:
# Stop the sync-server container
docker stop sync-server
# Make the sync-server container name available
docker rm sync-server
Now that you know how to run the sync-server, in the following sections you will learn how to start a match and report scores from the server.
Real-Time Gameplay Integrationβ
The Skillz SDK integration will be the same as your Async game modes. The difference comes with how you handle things when the match begins. Once the match begins you will need to validate the match is a Synchronous match then connect to the game server and start sending and receiving game state updates based upon your needs.
Currently we only have Unity example apps. You can still implement your real-time game utilizing the Skillz iOS or Android SDKs. Work with the Skillz team to answer questions regarding your specific platform.
Starting a Game (Match Start)β
When a match begins the Skillz SDK will call the OnMatchWillBegin
method (Unity implementation). Use matchInfo.IsCustomSynchronousMatch
to determine if the present match is a real-time match. Then proceed to check the Match
object to see if you have a server IP and port. If so you have a real-time match and can connect to the server and begin your game logic. If not you simply have an async match and can continue accordingly.
Please note that you should use the MatchId from the CustomServerConnectionInfo instance. This will be the unique id for the sync match.
string matchID = matchInfo.CustomServerConnectionInfo.MatchId;
void SkillzMatchDelegate.OnMatchWillBegin(Match matchInfo)
{
// Please note Unity provides a helper property to determine the match type
// if IsCustomSynchronousMatch is true we have a sync v2 match
if (matchInfo.IsCustomSynchronousMatch)
{
// get the needed info and handle your real-time game start
string matchID = matchInfo.CustomServerConnectionInfo.MatchId;
string hostName = matchInfo.CustomServerConnectionInfo.ServerIp;
string port = matchInfo.CustomServerConnectionInfo.ServerIp;
// ...
}
else
{
// handle your async game start
}
}
Reporting Scoresβ
Depending on your specific game setup, we provide either client-side or server-side score reporting.
Client-Side Score Reportingβ
For client-side score reporting, you submit scores the same way you would for an async game. Please read our score submitting docs for step-by-step instructions.
Server-Side Score Reporting (based on Skillz Sync Server SDK)β
Ensure you are on the latest version of the Sync Server SDK library jarfile. This has been updated to be sourced from a remote repository in recent versions of the Sync Server SDK Example Server Project. Follow the upgrade steps below if you have not done so, already.
- Make the following changes to the your top-level build.gradle file:
https://github.com/skillz/skillz-example-sync-server/pull/1/files#diff-49a96...
- If you've made no other customizations to the original build.gradle file, you can simply replace it with the completed file located here: https://github.com/skillz/skillz-example-sync-server/blob/v1.2.1/build.gradle
- Delete the
server_sdk.jar
file in theexample_sync_server/libs/
folder (the top-level folderexample_sync_server
may be named differently in your project)
Implementationβ
- In your custom Game class, in the overridden
broadcast()
orprocess()
function, call the newly availablereportScores()
function when the game is over. - This functionality relies on the following:
Client.score
must be set accordingly- If
Game.isAborted()
andGame.isForfeited()
are used to determine the end game state, ensure the following functions are used when tracking/setting game state:Game.forfeitGame(Client forfeitingClient)
Game.abortGame(Client abortingClient)
@Override
def broadcast() {
for (Player player in players as List<Player>) {
if (isGamePaused() || isResuming()) {
player.getMessageSender().sendOpponentConnection()
}
player.getMessageSender().sendGameStateUpdate();
}
if (completed || aborted) {
log.info("Game over for matchId: " + matchId + ", completed: " + completed + ", aborted: " + aborted);
// The game has ended and we want to report the scores to the Skillz platform now
reportScores()
for (Player player in players as List<Player>) {
log.debug("Sending GameOver to player: " + player.getUserId());
player.getMessageSender().sendGameOver()
}
reset();
}
}
Server-Side Score Reporting (custom or 3rd-party server)β
The Sync Event Service is responsible for handling HTTP POST requests from Sync game servers hosted on the Skillz platform. The URI of the service is exposed as an environment variable EVENT_SERVICE, while the endpoint for reporting scores is /realtime/v1/scores
. (The full URI of the report-score endpoint is thus ${EVENT_SERVICE}/realtime/v1/scores
.)
Your Skillz Game ID must be included as a header. For convenience, your game ID is exposed as an environment variable SKILLZ_GAME_ID
. The header must be included as X-Skillz-GameId
(see below for examples). In the future, this requirement will be removed and the Skillz platform will handle the inclusion of this Game ID header.
JSON Payloadβ
The payload is sent as JSON and follows this schema:
matchmaker_match_id
- The match UUID sent down by the Skillz Matchmaker to both clients upon finding a successful matchscores
- List of two JSON objects, each containing the following values:user_id
- User ID of the playerscore
- Score for this playerabort_state
- If the user has aborted, we omitscore
and instead include a value ofUNKNOWN
for abort_state. In the future, this functionality will be expanded to allow for more verbose abort states
- The
matchmaker_match_id
must be included with all requests.- At least one of the scores objects must include a
user_id
. (This might be the case if only one player were to ever connect to the match.)- Either a
score
orabort_state
must be sent for a given player. Including both ascore
andabort_state
object will result in an invalid request.
Below are examples of valid requests:
- Two Scores
- One Score and One Abort
- Two Aborts
- Only One Player Connected
{
"matchmaker_match_id": "f7c1e04f-6dc2-4af3-a875-5bdf541ae7c5",
"scores": [
{
"user_id": 2021938685,
"score": 100
},
{
"user_id": 14117213,
"score": 9001
}
]
}
{
"matchmaker_match_id": "f7c1e04f-6dc2-4af3-a875-5bdf541ae7c5",
"scores": [
{
"user_id": 2021938685,
"abort_state": "UNKNOWN"
},
{
"user_id": 14117213,
"score": 9004
}
]
}
{
"matchmaker_match_id": "f7c1e04f-6dc2-4af3-a875-5bdf541ae7c5",
"scores": [
{
"user_id": 2021938685,
"abort_state": "UNKNOWN"
},
{
"user_id": 14117213,
"abort_state": "UNKNOWN"
}
]
}
{
"matchmaker_match_id": "f7c1e04f-6dc2-4af3-a875-5bdf541ae7c5",
"scores": [
{
"user_id": 2021938685,
"score": 0
},
{
"abort_state": "UNKNOWN"
}
]
}
Example Implementationβ
Below is an example of reporting score in Groovy:
In the following example, the EventReporter
class exposes the following public methods:
reportScores(Long userId, Long userScore, Long opponentId, Long opponentScore, String matchmakerMatchId)
reportScoreAndAbort(Long userId, Long userScore, Long abortingOpponentId, String matchmakerMatchId)
reportDoubleAbort(Long abortingUserId, Long abortingOpponentId, String matchmakerMatchId)
Implementationβ
import com.google.gson.FieldNamingPolicy
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
import io.netty.handler.codec.http.HttpResponseStatus
import okhttp3.MediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.Response
@CompileStatic
class Event {
String matchmakerMatchId
}
@CompileStatic
class ScoreEvent extends Event {
List<Score> scores
}
@CompileStatic
class Score extends Event {
Long userId
Long score
String abortState
}
@Slf4j
@CompileStatic
class EventReporter {
private static final String EVENT_SERVICE = System.getenv('EVENT_SERVICE')
private static final String GAME_ID = System.getenv('SKILLZ_GAME_ID')
private static final String REPORT_SCORE_ENDPOINT = "/realtime/v1/scores"
private static final MediaType JSON = MediaType.get("application/json; charset=utf-8")
private static final int MAX_REQUEST_RETRIES = 3
private final OkHttpClient client = new OkHttpClient()
private final Gson gson = new GsonBuilder()
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.create()
void reportScores(Long userId, Long userScore, Long opponentId, Long opponentScore, String matchmakerMatchId) {
ScoreEvent scoreEvent = new ScoreEvent(
matchmakerMatchId: matchmakerMatchId,
scores: [
new Score(
userId: userId,
score: userScore
),
new Score(
userId: opponentId,
score: opponentScore
)])
log.debug("Reporting score for matchmaker match ID ${matchmakerMatchId}")
postEventWithRetry(REPORT_SCORE_ENDPOINT, scoreEvent)
}
void reportScoreAndAbort(Long userId, Long userScore, Long abortingOpponentId, String matchmakerMatchId) {
ScoreEvent abortEvent = new ScoreEvent(
matchmakerMatchId: matchmakerMatchId,
scores: [
new Score(
userId: userId,
score: userScore
),
new Score(
userId: abortingOpponentId,
abortState: "UNKNOWN"
)])
log.debug("Reporting score for matchmaker match ID ${matchmakerMatchId}")
postEventWithRetry(REPORT_SCORE_ENDPOINT, abortEvent)
}
void reportDoubleAbort(Long abortingUserId, Long abortingOpponentId, String matchmakerMatchId) {
ScoreEvent abortEvent = new ScoreEvent(
matchmakerMatchId: matchmakerMatchId,
scores: [
new Score(
userId: abortingUserId,
abortState: "UNKNOWN"
),
new Score(
userId: abortingOpponentId,
abortState: "UNKNOWN"
)])
log.debug("Reporting score for matchmaker match ID ${matchmakerMatchId}")
postEventWithRetry(REPORT_SCORE_ENDPOINT, abortEvent)
}
private void postEventWithRetry(String endpoint, Event event, int retries = 0) {
try {
if (retries >= MAX_REQUEST_RETRIES) {
log.error("Request to submit score was not accepted after ${retries} failures for matchmaker match ID ${event.matchmakerMatchId}!")
return
}
Response response = postEvent(endpoint, event)
if (response.code() != HttpResponseStatus.ACCEPTED.code()
&& response.code() != HttpResponseStatus.BAD_REQUEST.code()
&& response.code() != HttpResponseStatus.UNPROCESSABLE_ENTITY.code()) {
log.error("Request to submit score was not accepted for matchmaker match ID ${event.matchmakerMatchId}:\n${response.code()} - ${response.body()}")
postEventWithRetry(endpoint, event, ++retries)
}
} catch (IOException e) {
log.error("Exception thrown while attempting to report score for matchmaker match ID ${event.matchmakerMatchId}:\n${e.toString()}")
postEventWithRetry(endpoint, event, ++retries)
}
}
private Response postEvent(String endpoint, Event event) throws IOException {
def json = gson.toJson(event)
log.debug("Sending POST HTTP request to endpoint: ${endpoint} with json: ${json}")
RequestBody body = RequestBody.create(json, JSON)
Request request = new Request.Builder()
.url("https://" + EVENT_SERVICE + endpoint)
.header("X-Skillz-GameId", GAME_ID)
.post(body)
.build()
try (Response response = client.newCall(request).execute()) {
return response
}
}
}
Sync Server Container Image Requirementsβ
In order to deploy your Sync Server to real users, it is required that you provide Skillz a Docker Image Archive
that includes your Sync Server binary.
Here is a list of widely used bare bones Linux Based images to get you started:
And here is a list of widely used Java Linux Based images, which are useful if you are using the Sync Server SDK:
Whether your Sync Server is Room Based
or Ephemeral
, your container image must meet certain requirements, which we will cover in the following sections.
Room Based Server Docker Image Requirementsβ
A Room Based
server is one that supports multiple games on one server instance, where each room will have its own Room ID.
The requirements for the Docker Image for a Room Based
server are listed below:
- It must be Linux Based.
- It must be able to run as a
non-root
user. - It must expose only a single TLS-enabled, IPv4 TCP port.
Ephemeral Based Server Docker Image Requirementsβ
An Ephemeral
server is one where:
- The application must exit the process at the end of the match.
- The application must also exit the process if the match never starts or is ended prematurely.
- For example, when one player never shows up in a 1:1 match.
The requirements for the Docker Image for an Ephemeral
server are listed below:
- It must be Linux Based.
- It must be able to run as a
non-root
user. - It must expose only a single port over IPv4 or IPv6.
- We support UDP + TCP protocols.
Submitting the Sync Server Docker Image to Skillzβ
Preparing the Docker Image Archiveβ
To prepare a Docker Image Archive, follow the instructions below:
IMAGE_NAME="INSERT_IMAGE_NAME_HERE"
# Go to directory with Dockerfile
cd <FOLDER_WITH_DOCKERFILE>
# Build container image
docker build -t ${IMAGE_NAME} .
# Create image archive
docker save -o ./${IMAGE_NAME}-$(date +%Y%m%dT%H%M%S).tar.gz ${IMAGE_NAME}
The final image archive file will have the name of ${IMAGE_NAME}-$(date +%Y%m%dT%H%M%S).tar.gz
. This image archive will be used to deploy your Sync Server on Skillz infrastructure.
Contacting Skillzβ
Now that you are ready, please email Skillz at integrations@skillz.com and request the Skillz Sync Server functionality to be enabled.