Skip to content

Basic Raw Server

forbit edited this page May 5, 2022 · 1 revision

Basic Raw Server Documentation

This is an article describing the example project found in examples.

Navigation


Server

For the example, this uses the version 1.1, there may be a newer version available, but the code may differ.

Server Initialisation

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.

Server Packets

The server implements two custom packets, DisconnectPacket and LocationPacket

Location Packet

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 LocationPackets.

// 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.

Disconnect Packet

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.

Server Logic

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.


Client

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.

Client Initialisation

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 macros 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.

Client Packets

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.

Client Logic

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