Simple Godot -> Crystal TCP Script

Not sure if this is the correct section, feel free to move if need be!

Took me a while to get the awesome Godot Game Engine communicating with Crystal through TCP (not crystal’s fault, mostly mine! :P). So I figured I’d share an example script so others can have a head start.

simple_server.cr:

require "socket"
require "json"

MAX_PAYLOAD_SIZE = 5000 # To prevent CPU abuse. Ideally, rate limiting and iptable limits should be implemented as well

# Example of communicating to Godot through TCP using bidirectional JSON 
# Not to be used in production (any code by me)

class Client
  property user_id = 0
  property socket : TCPSocket

  def initialize(@socket = socket)
  end

  def send(msg, cmd = "SERV")
    msg_buffer = {cmd: cmd, message: msg}
    new_message = msg_buffer.to_json
    socket.write_bytes(new_message.bytesize)
    socket.send new_message
  end
end

class GameServer
  property clients = Array(Client).new

  def message_handler(msg, client)
    cmd = msg["cmd"]
    case cmd
    when "PING"
      # PONG
      client.send msg["message"], "PONG"
    end
  end

  # Each connection gets their own fiber
  def handle_connection(socket)
    begin
      puts "New connection: #{socket}"
      client = Client.new(socket)
      clients << client
      loop do
        payload_size = socket.read_bytes(UInt32)
        raise "Payload Size is too large or invalid. #{payload_size}" if payload_size > MAX_PAYLOAD_SIZE
        raw_message = socket.read_string(payload_size)
        # JSON.parse will raise JSON::ParseException error
        # This is really cool because any JSON data (keys) that don't match the server's, will raise an exception! Then you can disconnect that user for sending naughty stuff, etc
        msg = JSON.parse(raw_message)
        message_handler(msg, client)
      end
    rescue e #
      # Stream Error (user disconnected)
      clients.delete client
      puts "#{socket} disconnected. Connected Users : #{clients.size}"
    end
  end

  def initialize(ip, port)
    server = TCPServer.new(ip, port, 1000, true, true)
    server.tcp_nodelay = true # Very important for latency
    puts "GameServer started #{ip}:#{port}"

    while socket = server.accept?
      spawn handle_connection(socket)
    end
  end
end

GameServer.new("0.0.0.0", 9300)

simple_socket.gd

extends Node

var is_really_connected = false
var socket = StreamPeerTCP.new()
var TCPTimer = Timer.new()
var MSG_BUFFER = {}
var RTT = 0 # Round Trip Time

func _ready():
	socket.connect_to_host("127.0.0.1", 9300)
	TCPTimer.wait_time = 0.5 # Ping Interval
	TCPTimer.connect("timeout", self, "TCPTimeout")
	TCPTimer.start()
	add_child(TCPTimer)
	

func _process(delta):
	if socket.get_status() == 2 and !is_really_connected:
		socket.set_no_delay(true) # Important for gameservers
		print("Connected to server!")
		is_really_connected = true
	
	if is_really_connected:
		var bytes = socket.get_available_bytes()
		if bytes > 0:
			# Notice in crystal, we use u32 as well 
			var message = socket.get_utf8_string(socket.get_u32())
			message_handler(message)

func message_handler(msg):
	var json = JSON.parse(msg).result
	var cmd = json.cmd
	var new_message = json.message
	if cmd == "PONG":
		RTT = OS.get_ticks_msec() - new_message
		print("RTT: %sms" % RTT)
	print("Message Received: %s" % json)
	
func TCPTimeout():
	if is_really_connected:
		send_to_server(OS.get_ticks_msec())
		
func send_to_server(msg, cmd = "PING"):
	MSG_BUFFER.cmd = cmd
	MSG_BUFFER.message = msg
	# This looks like magic, but Godot silently sends the length of the msg in bytes ^^ nice!
	# (in crystal's server code, we send bytes before the message))
	socket.put_utf8_string(to_json(MSG_BUFFER))

Steps:

  • Start Server: crystal simple_server.cr
  • Attach the simple_socket.gd script to any node in your Godot scene
  • Preview Current Scene (F6) and check the consoles

To easily drag and drop onto any linux VPS, just do
crystal build simple_server.cr --static

Enjoy!

1 Like