.            ★.        . blessfrey.me ..          ☆        .

Godot 3 Tutorial - Chat Room Using WebSocket

Follow along to make your first mini WebSocket application in GDScript (with a little JSON). The client and the server will be two separate projects. I build upon the NetworkedMultiplayerENet chat room tutorial(archive link) by Miziziziz and the HTML5 and WebSocket tutorial in the Godot documentation.


Why WebSocket over UDP?

UDP is fast but inaccurate. It is best used for real-time action gameplay. TCP is slow but accurate. It is best for sharing data. You can read more about it on the Multiplayer doc page.

WebSocket uses a TCP connection. Ultimately, I am studying Godot to make a slow-paced adventure browser game, so this is one of the protocols I am considering and the protocol we will use in this tutorial.

Do I need a dedicated server for testing?

Nope! You can test client & server code on your own computer. I am only testing on a single computer at home for now without accessing an outside VPS, etc.


Make your projects.

Make one for the server and one for the client. You can do almost everything in the engine. If you export your projects after changes, though, you can easily see debug information for both sides of the application and test multiple clients. Godot is fast and light for a game engine, so I don't think it's unreasonable to add an exporting step to your workflow.

I named my projects ChatServer and ChatClient.

Setting up the server.

Node Hierarchy.

One scene with a node is enough for the server.

(Screenshot: Node hierarchy is just one node named Server.)

Declare your variables.


const PORT = 8080 
var _server = WebSocketServer.new()

PORT is the port your applications will use. If you are having trouble connecting, try 9001, etc, until you find a free one.

Build your skeleton.


func _ready():
	_server.connect('client_connected', self, "_connected")
	_server.connect('client_disconnected', self, "_disconnected")
	_server.connect('client_close_request', self, "_close_request")
	_server.connect("data_received", self, "_on_data_received")

	var err = _server.listen(PORT)
	if err != OK:
		print_debug("Unable to start server")
		set_process(false)
	print_debug("server started")
	
func _connected(id, protocol):
	print_debug("Client %d connected with protocol: 
        %s" % [id, protocol])
	
func _close_request(id, code, reason):
	print_debug("Client %d disconnecting with code: 
        %d, reason: %s" % [id, code, reason])
	
func _disconnected(id, was_clean = false):
	print_debug("Client %d disconnected, clean: 
        %s" % [id, str(was_clean)])
	
func _on_data_received(id):
	pass
	
func _process(_delta):
	_server.poll()

The _ready method prepares the server to catch all the signals it needs for a simple application. client_connected triggers when a client connects, client_disconnected when one disconnects, client_close_request when one requests to close, and data_received when a packet is received. Fairly obvious. The next block of code starts up the server, or at least makes an attempt.

So long as the process is running, the server polls constantly.

Let's start the client before we get any further.

Setting up the client.

Design work.

The client requires both some basic functionality and a barebones UI.

To go for this look...

(Screenshot: The look of the UI - a title bar on top, a large TextEdit field for everyone's messages, and a LineEdit beneath for entering messages. If you are 'logged out,' a username field and join button will appear at the bottom.)

...we'll need this tree.

(Screenshot: Node hierarchy is a Node named Client a Panel Container child named UI. The UI has a VBoxContainer child containing a Label, TextEdit, LineEdit, and an HBoxContainer. The HBox contains a LineEdit and a button.)

The root node will hold the programmatic half of the client, while the UI node will hold all the code for displaying things to the user.

To make UIs, I like to block everything out in containers, especially VBoxContainers and HboxContainers. I made the UI a PanelContainer so everything lays on the default gray background (or any custom background, choosable in the Inspector as a StyleBoxTexture).

(Screenshot: The PanelContainer Inspector>Theme Overrides>Styles>Panel>New StyleBoxTexture.)

Also in the Inspector, set the size (rect_size) to 250 by 600px. Make sure the Position is still (0,0). You can also match the window's size by going to the top-left menu>Project>Project Settings...>General>Display>Window>Size and set Width to 250 and Height to 600.

(Screenshot: PanelContainer Inspector>Rect>Size...x = 250 and y = 600.) (Screenshot: Project Settings.)

The layout is more vertical overall, so the first structural node is a VBoxContainer. Add it as a child, then go to Inspector>Size Flags. Check Fill and Expand for both Horizontal and Vertical, so it automatically fills the PanelContainer. When you check Fill and Expand, it will take up 100% of the space in that direction of its parent when it is the only child. If there are multiple children, each with Expand, they will evenly divide the space. If one child has fixed dimensions, it will stay that size, while all the Expand children evenly split up the remainder.

(Screenshot: VBoxContainer's Size Flags are checked for Horizontal Fill and Expand and Vertical Fill and Expand.)

For the Label, add the application's title in the Inspector's Text field, set Align to Center, and set the Size Flags to Horizontal Fill and Expand.

(Screenshot: VBoxContainer's Size Flags are checked for Horizontal Fill and Expand and Vertical Fill and Expand.)

For the ChatLog (TextEdit), check the Show Line Numbers and Wrap Enabled options then set Size Flags to Fill and Expand for both Horizontal and Vertical.

For the ChatInput (LineEdit), under Placeholder, set the Text to "Enter your message..." and the Size Flags to Fill and Expand Horizontally.

The next section is laid out horizontally, so use an HBoxContainer. Add a LineEdit for the Username with "Username" as its Placeholder Text and the Horizontal Fill and Expand boxes checked. Also add a button with its Text set to Join and its Horizontal Fill and Expand boxes checked.

Client Skeleton

Here is the first sketch of the UI code. The Client is accessible as get_parent(), the important nodes as variables, and the signals related to entering a message and pressing the join button are handled programmatically.


UI.gd (Client/UI PanelContainer)

extends PanelContainer

onready var chat_log = $VBox/ChatLog
onready var chat_input = $VBox/ChatInput
onready var join_button = $VBox/HBox/JoinButton
onready var username = $VBox/HBox/Name

func display_message(packet): 
    pass
	
func join_chat():
	pass
	
func _input(event):
	if event is InputEventKey:
		if event.pressed and event.scancode == KEY_ENTER:
			get_parent().send_message(chat_input.text)
			chat_input.text = ""
	
func _ready():
	join_button.connect("button_up", self, "join_chat")

UI always takes up so much space without actually doing that much...Let's block out the functionality on the root node's script.


Client.gd (Client Node)

extends Node

export var websocket_url = "ws://127.0.0.1:8080"
var _client = WebSocketClient.new()
onready var UI = $UI

var chat_name

func join_chat(username):
    pass
	
func send_message(message):
	pass
	
sync func receive_message(packet):
	pass

func _ready():
	_client.connect("connection_closed", self, "_closed")
	_client.connect("connection_error", self, "_error")
	_client.connect("connection_established", self, "_connected")
	_client.connect("data_received", self, "_on_data_received")

    var err = _client.connect_to_url(websocket_url)
	if err != OK:
		print_debug("Unable to connect")
		set_process(false)
	
func _error(was_clean = false):
	print_debug("Error, clean: ", was_clean)
	
func _closed(was_clean = false):
	print_debug("Closed, clean: ", was_clean)
	
func _connected(protocol = ""):
	print_debug("Connected with protocol: ", protocol)
	
func _on_data_received():
	pass
	
func _process(_delta):
	_client.poll()

It resembles the server. You can add a security protocol when you use wss:// instead of ws:/ for the websocket_url, but that is outside the scope of this tutorial. 127.0.0.1 loops back to your own computer, so you can use this for testing at home without involving an outside VPS, etc. Then, the port must match whatever you use for the server's PORT constant. 8080 ended up working for me.

And with that, we have enough to try it out.

Export & Run

Export just the server for now. If you want to try multiple clients, export the client, too, but there isn't a lot to test yet. To Export, go to the top-left menu>Project>Export... If you've been here before, choose the right presets for making a standalone application for your computer. Otherwise, on the top, click Add... then choose your operating system. When you have your preset, make sure under the Resources tab, 'Export all resources in the project' is selected. Otherwise, all the default stuff is good. Personally, I keep all my Godot exports in Exports folder next to my Godot projects, but you put it somewhere accessible. If you have an option, export in Debug Mode.

(Screenshot: Default export options for Linux.)

If you run in Linux, you can get the debug info in your terminal by opening a terminal in the export directory and typing ./ChatServer.x86_64 or whatever you named your application.

...Or you can just run both in their editors for now. No big deal.

(Screenshot: Both applications are running and connecting to each other.)

But however you run, if you run the server first and then the client, you will see your connection in the debug output! You have completed an extremely basic networking application. You can see the full project at this stage on my repo: server and client.


Communicating between Server + Client.

If we can pass one more hurdle, writing the chat room code will barely even need a tutorial: passing information between the server and client.

Writing packets in JSON.

Information will be sent in packets. You can literally pass your chat message strings, etc, as packets without much touchup. Something like put_packet("hey how's it going?".to_utf8()) will run, but even a little chat app will be passing lots of different data back and forth. JSON strings are freeform enough to fit any online application's needs. If you don't know JSON syntax, glance over the Godot documentation. It's very close to the dictionary in most languages.

Some examples of packets we'll use are test packets, chat message packets, server message packets, user joining packets, and user leaving packets. I'm going to use the 'type' key to distinguish incoming packets.

My chat message packets look like this:

{'type': 'chat_message', 'name': "Bell", 'message': "hey guys"}

To send them, you need to convert them to JSON strings then convert those strings into UTF-8. You will have to do the reverse on the receiving end. Outgoing packets will look like


_client.get_peer(1).put_packet(JSON.print(
    {'type': 'chat_message', 'name': "Bell", 'message': "hey guys"}).to_utf8()
)

while incoming messages will need extra preparation:


	var json = JSON.parse(_client.get_peer(1).get_packet().get_string_from_utf8())
	var packet = json.result

JSON packets in action.

Let's build up the server's data reception method.

Server.gd (Node)
func _on_data_received(id):
	
	var original = _server.get_peer(id).get_packet().get_string_from_utf8()
	var json = JSON.parse(original)
	var packet = json.result
	if typeof(packet) != 18:
		push_error("%s is not a dictionary" % [packet])
		get_tree().quit()
	
	print_debug("Got data from client %d: %s" % [id, packet])

We don't have to send the id argument manually. The server will understand the source of incoming data automatically. _server.get_peer(id) gives us the source. _server.get_peer(id).get_packet() gives us the packet they are sending.

typeof returns 18 for dictionaries. You can see all the types and their enum values in Godot's @GlobalScope documentation. Packets should always be dictionaries the way I'm writing this, so if anything else is received, the application will push an error and crash. Replace the code with a print_debug warning if you want, but I like the immediacy of a crash.

For now, it will print any valid packet's contents.

The client's data reception method should be similar:


Client.gd (Node)
func _on_data_received():
	var json = JSON.parse(_client.get_peer(1).get_packet().get_string_from_utf8())
	var packet = json.result
	if typeof(packet) != 18:
		push_error("%s is not a dictionary" % [packet])
		get_tree().quit()
	print_debug("Got data from server: ", packet)

Let's add a few put_packets to receive, too. The _connected methods on the server and client are the easiest places to test.


Server.gd (Node)
func _connected(id, protocol):
	print_debug("Client %d connected with protocol: %s" 
		% [id, protocol])
	_server.get_peer(id).put_packet(JSON.print(
		{'type': 'test', 'message': "test packet from server"}).to_utf8())

Client.gd (Node)
func _connected(protocol = ""):
	print_debug("Connected with protocol: ", protocol)
	_client.get_peer(1).put_packet(JSON.print(
		{'type': 'test', 'message': "test packet from client"}).to_utf8())

Let's see if the server and client can share data now.

(Screenshot: Godot's debug info echos both test packets sent from the server and client.)

Boom. That's enough networking knowledge to get you started. You can see the full project at this stage on my repo: server and client.


Writing that Chat Room.

Writing the UI.

A chat room is driven by the UI, so UI.gd is the best place to start. The UI needs to let the user join, send messages, and see messages sent by the user and others. Anything beyond showing buttons and letting the user type into fields is beyond the scope of the UI and will be passed to the root node.

(Screenshot: The ChatInput is hidden at the start.)

UI.gd (PanelContainer)
func display_message(packet): 
	if packet['type'] == "chat_message":
		chat_log.text += packet['name'] + ": " + packet['message'] + "\n"
	elif packet.has("message"):
		chat_log.text += packet['message'] + "\n"
	
func join_chat():
	if !username.text:
		display_message(
			{'type': "alert", 
				"message": "!!! Enter your username before joining chat."}
		)
	else:
		get_parent().join_chat(username.text)
		chat_input.show()
		join_button.hide()
		username.hide()
	
func _input(event):
	if event is InputEventKey and chat_input.text:
		if event.pressed and event.scancode == KEY_ENTER:
			get_parent().send_message(chat_input.text)
			chat_input.text = ""
	
func _ready():
	join_button.connect("button_up", self, "join_chat")

Also hide the ChatInput by clicking the eye on the node tree.

The display_message method will lightly format incoming chat messages and messages from other sources then show them as a line in the chat log. If the user clicks the join button but left the username field blank, an alert only visible to the user will be shown in the chat log. Otherwise, the join button and username field are hidden, and the chat input becomes available. join_chat is called off the parent, so the client can handle the rest. The _input method sends messages entered by the user to the root then clears the field for future messages.

Writing the first part of the client's back end.


Client.gd (Node)

export var websocket_url = "ws://127.0.0.1:8080"
var _client = WebSocketClient.new()
onready var UI = $UI
var chat_name

func join_chat(username):
	chat_name = username
	_client.get_peer(1).put_packet(JSON.print(
			{'type': "user_joined", 'name': username}
		).to_utf8()
	)
	
func send_message(message):
	_client.get_peer(1).put_packet(JSON.print(
			{'type': 'chat_message', 'name': chat_name, 'message': message}
		).to_utf8()
	)
	
func _error(was_clean = false):
	print_debug("Error, clean: ", was_clean)
	set_process(false)
	
func _closed(was_clean = false):
	print_debug("Closed, clean: ", was_clean)
	set_process(false)
	
func _connected(protocol = ""):
	print_debug("Connected with protocol: ", protocol)
	_client.get_peer(1).put_packet(JSON.print(
		{'type': 'test', 'message': "test packet from client"}
	).to_utf8())
	UI.join_button.show()
	UI.username.show()
	
func _on_data_received():
	var json = JSON.parse(
		_client.get_peer(1).get_packet().get_string_from_utf8()
	)
	var packet = json.result
	if typeof(packet) != 18:
		push_error("%s is not a dictionary" % [packet])
		get_tree().quit()
	print_debug("Got data from server: ", packet)

When the user clicks the join button, the client saves the username and sends it to the server. We can distinguish this from chat_messages, etc, by marking it as a "user_joined" packet. The client also sends the username and chat message from the UI's _input to the server. Both errors and voluntary or involuntary disconnections will stop the process. That covers everything we need to send messages. We'll cover receiving messages after we finish the server.

Writing the server.


Server.gd (Node)

const PORT = 8080
var _server = WebSocketServer.new()
var IDs = {}
var peers = []

func _ready():
	_server.connect('client_connected', self, "_connected")
	_server.connect('client_disconnected', self, "_disconnected")
	_server.connect('client_close_request', self, "_close_request")
	_server.connect("data_received", self, "_on_data_received")
	
	var err = _server.listen(PORT)
	if err != OK:
		print_debug("Unable to start server")
		set_process(false)
	print_debug("server started")
	
func _connected(id, protocol):
	print_debug("Client %d connected with protocol: %s" % [id, protocol])
	_server.get_peer(id).put_packet(JSON.print(
		{'type': 'test', 'message': "Server test packet"}).to_utf8()
	)
	peers.append(id)
	
func _close_request(id, code, reason):
	print_debug("Client %d disconnecting with code: %d, reason: %s" 
		% [id, code, reason])
	
func _disconnected(id, was_clean = false):
	print_debug("Client %d disconnected, clean: %s" 
		% [id, str(was_clean)])
	var user = IDs[id]
	peers.erase(id)
	for p in peers:
		_server.get_peer(p).put_packet(JSON.print(
			{'type': "server_message", "message": user 
				+ "(#" + str(id) + ") left the chat."}
		).to_utf8())
	
func _on_data_received(id):
	
	var original = _server.get_peer(id).get_packet().get_string_from_utf8()
	var json = JSON.parse(original)
	var packet = json.result
	if typeof(packet) != 18:
		push_error("%s is not a dictionary" % [packet])
		get_tree().quit()
	
	print_debug("Got data from client %d: %s ... echoing" % [id, packet])
	if packet['type'] == "user_joined": 
		IDs[id] = packet['name']
		for p in peers:
			_server.get_peer(p).put_packet(
				JSON.print(
					{'type': "server_message", "message": packet['name'] 
						+ "(#" + str(id) + ") joined the chat."}
					).to_utf8())
	elif packet['type'] == "chat_message" or packet['type'] == "server_message":
		for p in peers:
			_server.get_peer(p).put_packet(JSON.print(packet).to_utf8())
	
func _process(_delta):
	_server.poll()

Upon a connection, the _connected method adds the id of the connection to the peers array. Upon a disconnection, it is erased. This way, you can access all users at any time by looping through the peers array.

The most dynamic method is _on_data_received, since it will react to a variety of packets. If it's a user_joined packet, it will save the id of his connection and his chosen username to the ID dictionary. Then the server will send a server_message to every connected client to announce the new member. If it is some kind of message, it will also send that to every client so everyone can see the same messages.

Lastly, upon a disconnection, the server will send a server_message to all clients about the departure.

Now let's make sure the client is ready to receive all these messages...and we'll be done!

Finishing up the client.


Client.gd (Node)
func receive_message(packet):
	UI.display_message(packet)

func _on_data_received():
	var json = JSON.parse(
		_client.get_peer(1).get_packet().get_string_from_utf8()
	)
	var packet = json.result
	if typeof(packet) != 18:
		push_error("%s is not a dictionary" % [packet])
		get_tree().quit()
	print_debug("Got data from server: ", packet)
	if packet['type'] == "chat_message" or packet['type'] == "server_message":
		receive_message(packet) 

If the client receives a chat_message or server_message from the server, it will call receive_message, which passes it to the UI for formatting and display.

Export both your server and client, so you can open lots of clients and fill your room with lots of people. Everyone will be able to see people joining, leaving, and chatting. Just like a real chat app!

(Screenshot: A little chatplay using Jehoshaphat, Ahab, and Micaiah from 1 Kings 22.)

So the scrollbar covers some of the messages. And the scrolling is unusably annoying. The oldest messages should either disappear, or the chat should always be locked at the bottom on the latest messages. And there's no way to change your username without totally exiting and rejoining. And there must be some way to disable the pointless graphical window on the server. And there's probably a million more issues in addition to those.

But we're here to learn the basics, so we did an adequately good job. So good job anyway! Have fun improving from here and consider writing TCP guides of your own because UDP dominates the tutorial market. Later.^^


Check Over the Finished Scripts.

You can see the finished project on my repo: server and client.

Server side.


Server.gd (Node)

extends Node

const PORT = 8080
var _server = WebSocketServer.new()
var IDs = {}
var peers = []

func _ready():
	_server.connect('client_connected', self, "_connected")
	_server.connect('client_disconnected', self, "_disconnected")
	_server.connect('client_close_request', self, "_close_request")
	_server.connect("data_received", self, "_on_data_received")
	
	var err = _server.listen(PORT)
	if err != OK:
		print_debug("Unable to start server")
		set_process(false)
	print_debug("server started")
	
func _connected(id, protocol):
	print_debug("Client %d connected with protocol: %s" % [id, protocol])
	_server.get_peer(id).put_packet(JSON.print(
		{'type': 'test', 'message': "Server test packet"}).to_utf8()
	)
	peers.append(id)
	
func _close_request(id, code, reason):
	print_debug("Client %d disconnecting with code: %d, reason: %s" 
		% [id, code, reason])
	
func _disconnected(id, was_clean = false):
	print_debug("Client %d disconnected, clean: %s" 
		% [id, str(was_clean)])
	var user = IDs[id]
	peers.erase(id)
	for p in peers:
		_server.get_peer(p).put_packet(JSON.print(
			{'type': "server_message", "message": user 
				+ "(#" + str(id) + ") left the chat."}
		).to_utf8())
	
func _on_data_received(id):
	
	var original = _server.get_peer(id).get_packet().get_string_from_utf8()
	var json = JSON.parse(original)
	var packet = json.result
	if typeof(packet) != 18:
		push_error("%s is not a dictionary" % [packet])
		get_tree().quit()
	
	print_debug("Got data from client %d: %s ... echoing" % [id, packet])
	if packet['type'] == "user_joined": 
		IDs[id] = packet['name']
		for p in peers:
			_server.get_peer(p).put_packet(
				JSON.print(
					{'type': "server_message", "message": packet['name'] 
						+ "(#" + str(id) + ") joined the chat."}
					).to_utf8())
	elif packet['type'] == "chat_message" or packet['type'] == "server_message":
		for p in peers:
			_server.get_peer(p).put_packet(JSON.print(packet).to_utf8())
	
func _process(_delta):
	_server.poll()

Client side.


Client.gd (Node)
extends Node

export var websocket_url = "ws://127.0.0.1:8080"
var _client = WebSocketClient.new()
onready var UI = $UI
var chat_name

func join_chat(username):
	chat_name = username
	_client.get_peer(1).put_packet(JSON.print(
			{'type': "user_joined", 'name': username}
		).to_utf8()
	)
	
func send_message(message):
	_client.get_peer(1).put_packet(JSON.print(
			{'type': 'chat_message', 'name': chat_name, 'message': message}
		).to_utf8()
	)
	
func receive_message(packet):
	UI.display_message(packet)

func _ready():
	_client.connect("connection_closed", self, "_closed")
	_client.connect("connection_error", self, "_error")
	_client.connect("connection_established", self, "_connected")
	_client.connect("data_received", self, "_on_data_received")
	
	var err = _client.connect_to_url(websocket_url)
	if err != OK:
		print_debug("Unable to connect")
		set_process(false)
	
func _error(was_clean = false):
	print_debug("Error. Clean break? ", was_clean)
	set_process(false)
	
func _closed(was_clean = false):
	print_debug("Closed. Clean break? ", was_clean)
	set_process(false)
	
func _connected(protocol = ""):
	print_debug("Connected with protocol: ", protocol)
	_client.get_peer(1).put_packet(JSON.print(
		{'type': 'test', 'message': "test packet from client"}
	).to_utf8())
	UI.join_button.show()
	UI.username.show()
	
func _on_data_received():
	var json = JSON.parse(
		_client.get_peer(1).get_packet().get_string_from_utf8()
	)
	var packet = json.result
	if typeof(packet) != 18:
		push_error("%s is not a dictionary" % [packet])
		get_tree().quit()
	print_debug("Got data from server: ", packet)
	if packet['type'] == "chat_message" or packet['type'] == "server_message":
		receive_message(packet) 
	
func _process(_delta):
	_client.poll()


UI.gd (PanelContainer)

extends PanelContainer

onready var chat_log = $VBox/ChatLog
onready var chat_input = $VBox/ChatInput
onready var join_button = $VBox/HBox/JoinButton
onready var username = $VBox/HBox/Name

func display_message(packet): 
	if packet['type'] == "chat_message":
		chat_log.text += packet['name'] + ": " + packet['message'] + "\n"
	elif packet.has("message"):
		chat_log.text += packet['message'] + "\n"
	
func join_chat():
	if !username.text:
		display_message(
            {'type': "alert", "message": "!!! Enter your username before joining chat."}
        )
	else:
		get_parent().join_chat(username.text)
		chat_input.show()
		join_button.hide()
		username.hide()
	
func _input(event):
	if event is InputEventKey and chat_input.text:
		if event.pressed and event.scancode == KEY_ENTER:
			get_parent().send_message(chat_input.text)
			chat_input.text = ""
	
func _ready():
	join_button.connect("button_up", self, "join_chat")

Enjoy^^

Last updated February 26, 2022.