-
Notifications
You must be signed in to change notification settings - Fork 1
Basic Raw Server
This is an article describing the example project found in examples.
For the example, this uses the version 1.1
, there may be a newer version available, but the code may differ.
Firstly, start a new gradle project and then edit the build.gradle to include
// build.gradle
repositories {
..
maven { url 'https://jitpack.io' }
..
}
dependencies {
..
implementation 'dev.forbit:gm-server:1.1'
..
}
This will import the gm-server
library for use within your application. You may need to shade
the library inside your application if you want to run it on another system.
This project does also use lombok
, however the version shouldn't matter for this example.
// build.gradle
dependencies {
..
compileOnly 'org.projectlombok:lombok:1.18.24'
annotationProcessor 'org.projectlombok:lombok:1.18.24'
testCompileOnly 'org.projectlombok:lombok:1.18.24'
testAnnotationProcessor 'org.projectlombok:lombok:1.18.24'
..
}
Now, going inside the java files, we want to start by creating a Server
class that extends RawServer
, which is the protocol we will implement. The other option is GSONServer
, but this is slightly more complex to describe what is happening on the client side.
// Server.java
public class Server extends RawServer {
public Server(ServerProperties proerties) {
super(properties);
}
@Override public void onConnect() {
// ... connect logic
}
@Override public void onDisconnect() {
// ... disconnect logic
}
...
This is pretty boilerplate code, with no logic implemented yet. Now that we have our Server
class, we need a simple main function to call it.
// EntryPoint.java
public class EntryPoint {
public static void main(String[] args) {
Utilities.addLogOutputFile(Level.ALL, "./build/output.log");
ServerProperties properties = new ServerProperties("localhost", 21238, 22238);
Server server = new Server(properties);
server.init();
Utilities.getLogger().info("Started up server in main thread!");
}
}
This main function does several different things. Firstly, it creates a log file with Utilities.addLogOutputFile()
, all logging should be done through Utilities.getLogger()
, and this will both print to the console and to the log file.
Then we create a ServerProperties
instance with the address we want to host on and two ports, one for TCP
and the other for UDP
. These port numbers were chosen at random and can be anything you want.
Finally, server.init()
is called and this starts the server threads and begins the connection servers.
The server implements two custom packets, DisconnectPacket
and LocationPacket
The LocationPacket
is sent by the client whenever they move their character. As it is a frequently sent packet, and the information isn't necessary, it is sent via UDP. Essentially, whenever a LocationPacket
is received, the server updates this location, and then broadcasts this clients updated location to all other connected clients.
// LocationPacket.java
public class LocationPacket extends RawPacket {
@Setter int x;
@Setter int y;
@Setter UUID ID;
@Override public void fillBuffer(GMLOutputBuffer buffer) {
buffer.writeS32(x);
buffer.writeS32(y);
buffer.writeString(ID.toString()); // write the clients UUID
}
@Override public void loadBuffer(GMLInputBuffer buffer) {
x = buffer.readS32();
y = buffer.readS32();
}
...
This code shouldn't need too much explanation, aside from perhaps the writing of the UUID
in the packet. The UUID
is sent to each client, so that the clients can keep track of multiple clients. This will become clearer in the client logic section, where essentially a new object is created for each unique UUID
received via the LocationPacket
s.
// LocationPacket.java
...
@Override public void receive(Client client) {
Server server = (Server) this.getServer().getServer();
server.getLocations().put(client, new Location(x, y));
// broadcast location to all other clients
server.broadcastLocation(client);
}
}
The line Server server = (Server) this.getServer().getServer()
looks overly complex and confusing, and admittedly it is, but what's happening is this.getServer()
returns the ConnectionServer
which the packet was received on, either a TCPServer
or UDPServer
. And then the ConnectionServer#getServer()
method returns the abstract Server
object, and then finally this is cast to the custom Server
class that we created earlier.
The DisconnectPacket
is sent by the server to all other connected clients when another client disconnects. This distinction is important, as it should never be sent from the client to the server. This won't break anything, it just won't have the expected behaviour.
// DisconnectPacket.java
public class DisconnectPacket extends RawPacket {
@Getter @Setter UUID ID;
@Override public void fillBuffer(GMLOutputBuffer buffer) {
buffer.writeString(ID.toString());
}
@Override public void loadBuffer(GMLInputBuffer buffer) {
// we should never receive a disconnect buffer
}
@Override public void receive(Client client) {
// we should never receive this packet
}
}
The code in this packet is extremely straight foward. We simply load up the UUID
of the disconnected client and write it to our clients.
Before we dive deep into the logic on the server side, we need to create a Location
data class.
// Location.java
public @Data class Location {
int x;
int y;
public Location(int x, int y) {
setX(x);
setY(y);
}
}
Inside the Server
class, we're first going to start by creating a Map
that keeps track of all our client's locations.
// Server.java
...
@Getter private final Map<Client, Location> locations = new HashMap<>();
...
Now we need to firstly create two methods, one for sending the location of a client to another client, and then one to broadcast the location of one client to all other clients.
// Server.java
...
private void sendLocation(Client client, Client c) {
if (client.equals(c)) { return; }
var packet = new LocationPacket();
var location = getLocations().get(c);
packet.setX(location.getX());
packet.setY(location.getY());
packet.setID(c.getUUID());
this.sendPacketUDP(client, packet);
}
public void broadcastLocation(Client c) {
getClients().forEach(client -> sendLocation(client, c));
}
...
The sendLocation
method sends the location of c
to client
. The broadcastLocation
method sends all connected clients to location of client c
.
Lastly, we now need to change the onConnect
and onDisconnect
behaviour.
...
@Override public void onConnect(Client client) {
// send all the other connected clients locations to this client
locations.keySet().forEach(c -> sendLocation(client, c));
}
@Override public void onDisconnect(Client client) {
locations.remove(client);
var packet = new DisconnectPacket();
packet.setID(client.getUUID());
getClients().forEach(c -> sendPacketTCP(c, packet));
}
...
When a new client connects, we want to send them the locations of all previously connected clients, which is what happens in onConnect
. And then on the disconnect of a client, we tell all the other clients that this client has disconnected.
This is it for the Server.
The client is using GameMaker Studio 2, version 2022.1
. I hope that the version doesn't matter and that none of the code changes in time, but you never know for sure.
The start of the program begins in Room1
, inside the Creation Code
there is a call to the function init()
. This function is located inside the file init. Let's take a look at the file.
// init.gml
#macro ADDRESS "localhost"
#macro TCP_PORT 21238
#macro UDP_PORT 22238
#macro BUFFER_SIZE 1024
#macro REGISTRATION_PACKET_ID "dev.forbit.identifier.RegisterPacket"
#macro CONNECTION_PACKET_ID "dev.forbit.server.networks.raw.packets.RawConnectionPacket"
#macro PING_PACKET_ID "dev.forbit.server.networks.raw.packets.RawPingPacket"
#macro LOCATION_PACKET_ID "dev.forbit.networking.packets.LocationPacket"
#macro DISCONNECT_PACKET_ID "dev.forbit.networking.packets.DisconnectPacket"
function init() {
global.ping = -3;
global.tcp_status = undefined;
global.udp_status = undefined;
global.udp_socket = noone;
global.tcp_socket = noone;
global.uuid = -1;
connect_tcp();
}
This file contains a bunch of macro
definitions. These can be changed according to whatever you're building. It is personal preference to use macro
s as a way of describing the packet names, you can go into the code and change these to strings themselfs. However, it is important that these are identical to the Java classnames inside the server. If these are not identical and are sent to the server, the server will not like it.
One should note that the ADDRESS
, TCP_PORT
, and UDP_PORT
are identical to those described in the ServerProperties
instance created earlier. BUFFER_SIZE
defaults to 1024 for packets, and should be enough for most things.
The function init()
simply sets up some global variables to let us know our status int he connection cylce. The variable global.ping
will start at -3
, progress to -2
when beginning to connect to the TCPServer
, to -1
when beginning to connect to the UDPServer
, and finally when it starts pinging it should be at a value >= 0
. Once the server has connected to both TCPServer
and UDPServer
, global.uuid
will be the UUID that was assigned on the server side.
Finally, this script calls connect_tcp()
, which is located in the networking
scripts file.
// networking.gml
function connect_tcp() {
// connect to tcp server first
global.tcp_socket = network_create_socket(network_socket_tcp);
global.udp_socket = network_create_socket(network_socket_udp);
global.uuid = -1;
global.udp_status = -1;
global.ping = -2;
global.tcp_status = network_connect_raw(global.tcp_socket, ADDRESS, TCP_PORT);
show_debug_message("connected to tcp. status: "+string(global.tcp_status));
global.ping = -1;
}
function connect_udp() {
global.udp_status = network_connect_raw(global.udp_socket, ADDRESS, UDP_PORT);
show_debug_message("connected to udp. status: "+string(global.udp_status));
var connection_buffer = buffer_create(BUFFER_SIZE, buffer_fixed, 1);
buffer_write(connection_buffer, buffer_string, REGISTRATION_PACKET_ID);
buffer_write(connection_buffer, buffer_string, global.uuid);
network_send_raw(global.udp_socket, connection_buffer, buffer_tell(connection_buffer));
if (udp_status >= 0) {
global.connected = true;
connected();
}
}
These functions establish a connection to the TCPServer
, and then when we receive a call back from the TCPServer
, we then call connect_udp()
. This happens inside the async - networking
event in obj_client
. Once connect_udp()
is called, and we successfully send our UUID to the UDPServer, which is necessary for the Server to keep track both of a clients connections in one Client
object, we set global.connected
to be true, and finally call the function connected()
. It is recommended that you don't touch these two functions, unless you know what you are doing. You can however, have free reign over connected()
.
// networking.gml
function connected() {
// create player
instance_create_layer(room_width/2, room_height/2, "Instances", obj_player);
// begin pinging
alarm[0] = 1;
}
This function does two things, it creates a player object in the centre of the room, and starts an alarm with the execution 1 frame after. Since this function is called by obj_client
, this alarm will execute as obj_client
. This alarm begings the pinging cycle, which is useful information for the client to know if they're still connected or not.
Let's look inside obj_client
into the async - networking
event.
/// obj_client
// Other_68.gml
switch (async_load[? "type"]) {
case network_type_data: { // data receieved
var buffer = async_load[? "buffer"]; // get the buffer
buffer_seek(buffer, buffer_seek_start, 0); // go to the start of the buffer
var header = buffer_read(buffer, buffer_string); // get the header (package & class of packet)
if (header == CONNECTION_PACKET_ID) {
// get the UUID from connection packet
var uuid = buffer_read(buffer, buffer_string);
show_debug_message("uuid: "+string(uuid));
global.uuid = string(uuid); // set global.uuid to recieved uuid
connect_udp(); // connect to udp server after recieving uuid, because we must send it to udp server
// this is so the UDP server knows which client we are based off of our address
} else {
handle_packet(header, buffer); // handle the packet
}
break;
}
}
Now there may look like a lot of things are happening, but all this is receiving a buffer from either server, and then doing 2 things depending on what the packet is. The line var header = buffer_read(buffer, buffer_string)
reads the buffer name sent from the Server
. This will be the java classname of whatever packet is sent, and if you're following, should be one of the strings located in the macro section of init()
.
The code will check if the packet we received is a registration packet, and will then do some decoding to determine our UUID. It will print this UUID to the console, and then call connect_udp()
. If the packet is not a registration packet, then it hands off the header and the rest of the buffer to handle_packet()
.
// handle_packets.gml
function handle_packet(header, buffer){
show_debug_message("received packet: "+string(header));
switch(header) {
case PING_PACKET_ID: {
var sent_time = buffer_read(buffer, buffer_s32);
if (sent_time > 0) {
global.ping = current_time-sent_time;
}
alarm[0] = room_speed;
break;
}
case LOCATION_PACKET_ID: {
var client_x = buffer_read(buffer, buffer_s32);
var client_y = buffer_read(buffer, buffer_s32);
var client_id = buffer_read(buffer, buffer_string);
var inst = get_client_character(client_id);
inst.x = client_x;
inst.y = client_y;
break;
}
case DISCONNECT_PACKET_ID: {
var client_id = buffer_read(buffer,buffer_string);
var inst = get_client_character(client_id);
if (inst > noone) {
instance_destroy(inst);
}
break;
}
default: break;
}
}
Now this function is quite heavy, so I'm not going to go over everything. The PingPacket
essentially compares the time received from the packet to the current_time
, and then sends back the same information. The LocationPacket
reads in an x
and a y
value and an associated uuid
. It then attempts to find a client character with a matching UUID
, and then updates their position. If the get_client_character()
function cannot find a character, it creates a new one. Finally, the DisconnectPacket
removes a client character from the room if one exists.
The sending of packets is quite repetitive and straightfoward. Here is the code for sending a location packet
// location_packet.gml
function send_location_packet(x, y) {
// sends location packet to udp server
var packet = buffer_create(BUFFER_SIZE, buffer_fixed, 1); // create new buffer
buffer_seek(packet, buffer_seek_start,0); // go to start of buffer
buffer_write(packet, buffer_string, LOCATION_PACKET_ID); // class name
buffer_write(packet, buffer_s32, x);
buffer_write(packet, buffer_s32, y);
network_send_raw(global.udp_socket, packet, buffer_tell(packet)); // send to udp server
}
This simply creates a new buffer
with size 1024
, and writes the LOCATION_PACKET_ID
macro, defined in init()
. It then fills the x
and y
values and sends the packet to the UDPServer
.
Lets start with the simple function get_client_character()
// location_packet.gml
function get_client_character(client_id) {
var inst = noone;
with (obj_network_character) {
if (network_id == client_id) {
inst = id;
}
}
if (inst == noone) {
inst = instance_create_layer(room_width/2, room_height/2, "Instances", obj_network_character);
inst.network_id = client_id;
}
return inst;
}
This does two things, first it checks for an instance of obj_network_character
with a matching network_id
to the client_id
. If it can't find one, it then creates a new instance and associates the network_id
with the client_id
. It then returns either the created instance, or the found instance.
Inside obj_player
, is some basic movement code.
/// obj_player
// Step_0.gml
var move_x = keyboard_check(ord("D")) - keyboard_check(ord("A"));
var move_y = keyboard_check(ord("S")) - keyboard_check(ord("W"));
var move_speed = 4;
x += move_x * move_speed;
y += move_y * move_speed;
x = clamp(x,0,room_width);
y = clamp(y,0,room_height);
if (move_x != 0 || move_y != 0) {
// send move packet
send_location_packet(x,y);
}
This code should be self-explanatory if you can understand GML
. The bit at the bottom essentially asks if we've moved as the player and if we have, then send a new location packet.
And that concludes the breakdown. If you have any remaining questions, feel free to contact me through my website