Repository: SSS-Says-Snek/hisock (language: python)

Issue:
Callback and error handling callbacks to start, threaded close "works"
Adding callbacks for just a general callback that happens every time _update or _run finishes and for when an error occurs.

API breakage (because of course), however, this PR will also solve the threads... not actually being able to close (sorry 'bout that)

Please code review.


Discussion / hints:

Fix patch:
diff --git a/.gitignore b/.gitignore
index b6e2273..dee4739 100644
--- a/.gitignore
+++ b/.gitignore
@@ -54,6 +54,7 @@ cover/
 
 # Sphinx
 docs/build/
+docs/source/_build/
 
 # Translations
 *.mo
diff --git a/README.md b/README.md
index ec76900..b32e2c0 100644
--- a/README.md
+++ b/README.md
@@ -57,7 +57,7 @@ import sys
 import time
 import random
 
-from hisock import start_server, get_local_ip, iptup_to_str
+from hisock import start_server, get_local_ip
 
 ADDR = get_local_ip()
 PORT = 6969
@@ -72,19 +72,19 @@ server = start_server((ADDR, PORT))
 
 @server.on("join")
 def client_join(client_data):
-    print(f"Cool, {':'.join(map(str, client_data['ip']))} joined!")
+    print(f"Cool, {client_data.ip_as_str} joined!")
     if client_data['name'] is not None:
-        print(f"    - With a sick name \"{client_data['name']}\", very cool!")
+        print(f"    - With a sick name \"{client_data.name}\", very cool!")
     if client_data['group'] is not None:
-        print(f"    - In a sick group \"{client_data['group']}\", cool!")
+        print(f"    - In a sick group \"{client_data.group}\", cool!")
 
     print("I'm gonna send them a quick hello message")
 
     server.send_client(client_data['ip'], "hello_message", str(time.time()).encode())
 
 @server.on("processing1")
-def process(client, process_request: str):
-    print(f"\nAlright, looks like {iptup_to_str(client['ip'])} received the hello message, "
+def process(client_data, process_request: str):
+    print(f"\nAlright, looks like {client_data.ip_as_str} received the hello message, "
           "\nas now they're trying to compute something on the server, because they have "
           "potato computers")
     print("Their processing request is:", process_request)
@@ -96,11 +96,10 @@ def process(client, process_request: str):
     result = eval(process_request)  # Insecure, but I'm lazy, so...
     print(f"Cool! The result is {result}! I'mma send it to the client")
 
-    server.send_client_raw(client['ip'], str(result).encode())
+    server.send_client(client_data, "something", str(result))
 
 
-while not server.closed:
-    server.run()
+server.start()
 ```
 
 #### Client
@@ -130,28 +129,26 @@ if name == '':
 if group == '':
     group = None
 
-join_time = time.time()
-
-s = connect(
+client = connect(
     (server_to_connect, port),
     name=name, group=group
 )
+join_time = time.time()
 
 
-@s.on("hello_message")
-def handle_hello(msg):
+@client.on("hello_message")
+def handle_hello(msg: str):
     print("Thanks, server, for sending a hello, just for me!")
-    print(f"Looks like, the message was sent on timestamp {msg.decode()}, "
-          f"which is just {round(float(msg.decode()) - join_time, 6) * 1000} milliseconds since the connection!")
+    print(f"Looks like, the message was sent on timestamp {msg}, "
+          f"which is just {round(float(msg) - join_time, 6) * 1000} milliseconds since the connection!")
     print("In response, I'm going to send the server a request to do some processing")
 
-    s.send("processing1", b"randnum**2")
-    result = int(s.recv_raw())
+    client.send("processing1", b"randnum**2")
+    result = client.recv("something", int)
 
     print(f"WHOAAA! The result is {result}! Thanks server!")
 
-while not s.closed:
-    s.update()
+client.start()
 
 ```
 
diff --git a/cleancode.py b/cleancode.py
index 52c9338..7d3c909 100644
--- a/cleancode.py
+++ b/cleancode.py
@@ -1,6 +1,7 @@
 """
 A simple script to automate code cleanups. Not stolen from PygameCommunityBot btw.
 """
+
 import glob
 import os
 import pathlib
diff --git a/docs/source/api/client.rst b/docs/source/api/client.rst
new file mode 100644
index 0000000..51668c0
--- /dev/null
+++ b/docs/source/api/client.rst
@@ -0,0 +1,22 @@
+hisock.client
+=============
+
+A module containing the main client classes and functions, including
+:class:`HiSockClient` and :func:`connect`
+
+.. autoclass:: hisock.client.HiSockClient
+   :members:
+
+----
+
+.. autoclass:: hisock.client.ThreadedHiSockClient
+
+   .. automethod:: join
+   .. automethod:: start
+   .. automethod:: stop
+
+----
+
+.. autofunction:: hisock.client.connect
+
+.. autofunction:: hisock.client.threaded_connect
diff --git a/docs/source/api/client_doc.rst b/docs/source/api/client_doc.rst
deleted file mode 100644
index 5f6559a..0000000
--- a/docs/source/api/client_doc.rst
+++ /dev/null
@@ -1,33 +0,0 @@
-hisock.client
-=============
-
-A module containing the main client classes and functions, including
-:class:`HiSockClient` and :func:`connect`
-
-.. autoclass:: hisock.client.HiSockClient
-
-   .. automethod:: change_group
-   .. automethod:: change_name
-   .. automethod:: close
-   .. automethod:: on
-   .. automethod:: send_raw
-   .. automethod:: recv_raw
-   .. automethod:: send
-   .. automethod:: start
-   .. automethod:: update
-   .. note::
-
-      This is the main method to run HiSockClient. This **MUST** be called
-      every iteration in a while loop, as to keep waiting time as short as possible
-      between client and server. It is also recommended to put this in a thread.
-
-.. autoclass:: hisock.client.ThreadedHiSockClient
-
-   .. automethod:: join
-   .. automethod:: run
-   .. automethod:: start_client
-   .. automethod:: stop_client
-
-.. autofunction:: hisock.client.connect
-
-.. autofunction:: hisock.client.threaded_connect
diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst
index 6f2d7da..5b40b06 100644
--- a/docs/source/api/index.rst
+++ b/docs/source/api/index.rst
@@ -5,6 +5,6 @@ These pages provide you with information about the functions available in ``hiso
 
 .. toctree::
 
-   hisock.server <server_doc>
-   hisock.client <client_doc>
-   hisock.utils <utils_doc>
\ No newline at end of file
+   hisock.server <server>
+   hisock.client <client>
+   hisock.utils <utils>
diff --git a/docs/source/api/server.rst b/docs/source/api/server.rst
new file mode 100644
index 0000000..2ee646c
--- /dev/null
+++ b/docs/source/api/server.rst
@@ -0,0 +1,26 @@
+hisock.server
+=============
+
+A module containing the main server classes and functions, including
+:class:`HiSockServer` and :func:`start_server`
+
+.. note::
+
+   Header lengths shouldn't be too long. By default, the header length is 16 bytes. This is good enough for most applications, as allows for 10**16 bytes to be sent.
+
+.. autoclass:: hisock.server.HiSockServer
+   :members:
+
+----
+
+.. autoclass:: hisock.server.ThreadedHiSockServer
+
+   .. automethod:: join
+   .. automethod:: start
+   .. automethod:: stop
+
+----
+
+.. autofunction:: hisock.server.start_server
+
+.. autofunction:: hisock.server.start_threaded_server
diff --git a/docs/source/api/server_doc.rst b/docs/source/api/server_doc.rst
deleted file mode 100644
index 7bc58a2..0000000
--- a/docs/source/api/server_doc.rst
+++ /dev/null
@@ -1,49 +0,0 @@
-hisock.server
-=============
-
-A module containing the main server classes and functions, including
-:class:`HiSockServer` and :func:`start_server`
-
-.. note::
-
-   Header lengths usually should not be that long;
-   on average, header lengths are about 64 bytes long, which is more than
-   enough for most cases (10 vigintillion, 10**64). Plus, a release is planned
-   for hisock that utilizes ints to bump the header utilization from
-   `10**x` to `2**(7x)` (where x is the header length)
-
-.. autoclass:: hisock.server.HiSockServer
-
-   .. automethod:: close
-   .. automethod:: disconnect_all_clients
-   .. automethod:: disconnect_client
-   .. automethod:: get_addr
-   .. automethod:: get_all_clients
-   .. automethod:: get_client
-   .. automethod:: get_group
-   .. automethod:: on
-   .. automethod:: recv_raw
-   .. automethod:: run
-   .. note::
-
-      This is the main method to run HiSockServer. This **MUST** be called
-      every iteration in a while loop, as to keep waiting time as short as possible
-      between client and server. It is also recommended to put this in a thread.
-   .. automethod:: send_all_clients
-   .. automethod:: send_client
-   .. automethod:: send_client_raw
-   .. automethod:: send_group
-   .. automethod:: send_group_raw
-   .. automethod:: start
-
-.. autoclass:: hisock.server.ThreadedHiSockServer
-
-   .. automethod:: join
-   .. automethod:: run
-   .. automethod:: start_server
-   .. automethod:: stop_server
-
-
-.. autofunction:: hisock.server.start_server
-
-.. autofunction:: hisock.server.start_threaded_server
\ No newline at end of file
diff --git a/docs/source/api/utils_doc.rst b/docs/source/api/utils.rst
similarity index 51%
rename from docs/source/api/utils_doc.rst
rename to docs/source/api/utils.rst
index 1570d7c..d6ff2ea 100644
--- a/docs/source/api/utils_doc.rst
+++ b/docs/source/api/utils.rst
@@ -4,11 +4,17 @@ hisock.utils
 A module containing some utilities to either:
 
 1. Help :mod:`hisock.client` and :mod:`hisock.server` run (denoted with leading underscore)
+1. Provide functions to use alongside :mod:`HiSock`
 
-2. Provide functions to use alongside hisock
+.. autoclass:: hisock.utils.ClientInfo
+   :members:
 
+.. autofunction:: hisock.utils.make_header
+.. autofunction:: hisock.utils.receive_message
+.. autofunction:: hisock.utils.validate_command_not_reserved
+.. autofunction:: hisock.utils.validate_ipv4
 .. autofunction:: hisock.utils.get_local_ip
-.. autofunction:: hisock.utils.input_client_config
 .. autofunction:: hisock.utils.input_server_config
+.. autofunction:: hisock.utils.input_client_config
 .. autofunction:: hisock.utils.ipstr_to_tup
-.. autofunction:: hisock.utils.iptup_to_str
\ No newline at end of file
+.. autofunction:: hisock.utils.tup_to_ipstr
\ No newline at end of file
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 396eb5e..fe36992 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -6,6 +6,7 @@
 import json
 
 import urllib.request
+from urllib.error import HTTPError
 
 path = pathlib.Path(os.path.dirname(__file__))
 
@@ -26,12 +27,15 @@
 
 from hisock import constants
 
-
-version_html = json.loads(
-    urllib.request.urlopen(
-        "https://api.github.com/repos/SSS-Says-Snek/hisock/releases/latest"
-    ).read()
-)
+try:
+    version_html = json.loads(
+        urllib.request.urlopen(
+            "https://api.github.com/repos/SSS-Says-Snek/hisock/releases/latest"
+        ).read()
+    )
+except HTTPError:
+    # Most likely rate-limited
+    version_html = {}
 
 # Configuration file for the Sphinx documentation builder.
 #
@@ -62,7 +66,7 @@
 try:
     release = version_html["tag_name"]
 except KeyError:
-    # Most likely rate limited
+    # Most likely rate-limited
     release = constants.__version__  # Fall back on latest known release
 
 # -- General configuration ---------------------------------------------------
diff --git a/docs/source/imgs/intermed_tut/group.png b/docs/source/imgs/intermed_tut/group.png
deleted file mode 100644
index 6a395ba..0000000
Binary files a/docs/source/imgs/intermed_tut/group.png and /dev/null differ
diff --git a/docs/source/imgs/intermed_tut/name.png b/docs/source/imgs/intermed_tut/name.png
deleted file mode 100644
index 1ec597b..0000000
Binary files a/docs/source/imgs/intermed_tut/name.png and /dev/null differ
diff --git a/docs/source/index.rst b/docs/source/index.rst
index 0e0d221..ef15530 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -3,24 +3,19 @@
    You can adapt this file completely to your liking, but it should at least
    contain the root `toctree` directive.
 
-Welcome to hisock's documentation!
+Welcome to HiSock's documentation!
 ==================================
 
-hisock is a higher-level extension of the `socket` module,
-with simpler and more efficient usages. It completely removes the hassle of headers, and
-utilizes decorators to maintain a good code structure.
+:mod:`hisock` (also called ``Hisock`` and ``HiSock``) is a higher-level extension of the `socket` module, with simpler and more efficient usages. It completely removes the hassle of headers, and utilizes decorators to maintain a good code structure.
 
 .. note::
    This project is still currently under development.
-   This is also the first package of the developer, SSS_Says_Snek, so if you find something
-   that needs improvement, submit an issue on the `GitHub Repository <https://github.com/SSS-Says-Snek/hisock/>`_
+   This is also the first package of the developer, SSS-Says-Snek, so if you find something that needs improvement, submit an issue on the `GitHub Repository <https://github.com/SSS-Says-Snek/hisock/>`_
 
 .. toctree::
    :maxdepth: 2
    :caption: Contents:
 
-
-
 Table of Contents
 ==================
 
@@ -33,9 +28,10 @@ Table of Contents
 
    quickstart/index
    tutorials/index
+   examples/index
    API Reference <api/index>
 
 .. toctree::
    :maxdepth: 1
-   
-   Changelog <changelog>
+
+   Changelog <changelog>
\ No newline at end of file
diff --git a/docs/source/quickstart/beginner_tutorial.rst b/docs/source/quickstart/beginner_tutorial.rst
deleted file mode 100644
index 64ff9fa..0000000
--- a/docs/source/quickstart/beginner_tutorial.rst
+++ /dev/null
@@ -1,310 +0,0 @@
-Tutorial
-========
-
-In this "tutorial", I'm going to be explaining the basic parts of ``hisock``,
-and how to use them to create working programs.
-
-It is also highly encouraged that you read the :doc:`Understanding Hisock <understanding_hisock>`
-before reading the tutorial.
-
-.. caution::
-   I might explain some things wrong, so if you see something wrong with my
-   explanation, I encourage you to try to submit a pull request on Github to
-   fix it!
-
-.. note::
-   Due to certain reasons, for the client part, I will be referring
-   the server's IP as ``utils.get_local_ip()``, the same IP address
-   as where the server is hosted at. Of course, in real
-   applications, there will either be input for the server IP,
-   or a hardcoded server IP
-
-First of all, we need to install hisock. Assuming you have ``pip``,
-you can install hisock with either ``python -m pip install hisock`` for
-Windows, or ``pip3 install hisock`` for Mac/Linux.
-Refer to :doc:`the installation guide <installation>` for more details.
-
-First of all, network structures are divided into *servers* and
-*clients*. Servers basically *serve* data, and can also be a mean of
-communicating between clients. Clients are just the users that give and receive
-the server data.
-
-.. note::
-   For example, if you are creating a multiplayer game,
-   you can create a *server* that *clients* need to join; once they do, client ``A``
-   can ask the server to share some info to client ``B``, and so on and so forth.
-
-Creating our first server
--------------------------
-
-There is a function to create a server in ``hisock``, it is ``hisock.start_server()``.
-To host a server, we'd need a tuple with two arguments; the first argument is the IP address
-of the server. Hisock provides a way to find your local IP address, with
-``hisock.utils.get_local_ip()``. The second argument is the *port* of the server.
-I won't go into ports, but usually a number between 1024 and 65535 would suffice.
-
-Hisock servers and clients have a ``run()`` and ``update()`` method respectively,
-in order to run correctly. However, you need to run them in a while loop, as to
-minimize lag time. So, our final server script looks like:
-
-.. code-block:: python
-
-   from hisock import start_server, utils
-
-   server = start_server((utils.get_local_ip(), 36969))  # Haha funny
-
-   while not server.closed:
-       server.run()
-
-Why did we include a ``not server.closed``? Well, if we only used ``while True``, then even if
-the server closed for some reason, the program won't exit because of that ``True``! So, because
-``.closed`` will be True if the server is closed, the while loop will stop running, which is what we want.
-
-That's basically it! Of course, this server is useless, but hey, it's a step
-in the right direction! We'll add on to this later on.
-
-Creating our first client
--------------------------
-
-Obviously, without a client, a server's kind of pointless. So, let's spice things up,
-with some boilerplate client code!
-
-Now, the first thing we need to do, is to connect to the server. We can do that with
-hisock's ``connect()`` function. Like ``start_server()``, it takes one tuple as an
-argument, with two elements; The first one is the IP of the server, and the second one
-is the port of the server.
-
-Also like ``HiSockServer``, ``HiSockClient`` needs to be ran in a while loop. However,
-in ``HiSockClient``, instead of the ``run()`` method, it is called ``update()``. So,
-our final boilerplate client code is:
-
-.. code-block:: python
-
-   from hisock import connect, utils
-
-   # This is a bit tricky - This will only work on the same computer
-   # running the server, as it gets the same IP (unless you port forward)
-   client = connect((utils.get_local_ip()), 36969)
-
-   while not client.closed:
-       client.update()
-
-As you can see, we also use ``while not client.closed:`` here for the same reason as the server.
-Also like the server, this doesn't do anything at all yet, but soon, we'll
-finally add some functionality to the server and client!
-
-.. _clearstuff:
-
-Clearing some things up
------------------------
-
-Let me clear some stuff up first;
-data sent with :mod:`hisock` usually has a *command* before the data.
-Once the command is sent,
-it can be received using decorators ``hisock.server.on`` and ``hisock.client.on``.
-An argument will be passed in those decorators that specifies the command to listen data
-with those commands. When data with the command attached is found, ``hisock`` will
-call that function, and pass in a few arguments regarding message content
-(``hisock.server`` will also have an argument about the client data).
-
-Let's start with a decorator example for the server
-
-.. code-block:: python
-
-   # Server
-   server = ...
-
-   @server.on("random_command")
-   def random_cmd_handler(clt_data, message):
-       # clt_data is a dict of client information
-       # message is the data content, in bytes
-
-       print(message)
-
-If any data is found with the command "random_command" attached before it, then
-it will call ``random_cmd_handler()``, filling in the parameters with the appropriate values.
-
-Finally, we have an example of the client
-
-.. code-block:: python
-
-   client = ...
-
-   @client.on("another_random_command")
-   def handler_thing(message):
-       # No clt_data, as server always sends message
-       print(message)
-
-This isn't much different to the server; any data that has the command "another_random_command"
-attached to it, will automatically call ``handler_thing()``, albeit with less parameters
-
-Now that we've done that, let's add functionality to our bland server and client!
-
-Adding (some) functionality to our server
------------------------------------------
-
-So far, we have made a server and client, but it doesn't really *do* anything.
-So, it's time to add some functionality, starting with the server!
-
-Now, let's say that we want to print the client's IP on the server side,
-whenever the server connects to a client. ``hisock`` provides something I like to call
-"reserved functions", where there are certain commands that get attached to data that
-occur on very special events. For server, there are a few, including:
-
-1. ``join`` occurs whenever a client connects
-2. ``leave`` occurs whenever a client disconnects
-3. ``message`` occurs whever a client sends a "message"
-
-(I mean, they're pretty self-explanatory)
-
-Anyways, we can use the ``join`` reserved function to print the client's IP, like so:
-
-.. code-block:: python
-
-   # Server
-   ...
-   server = ...
-
-   @server.on("join")
-   def clt_join(clt_data):  # Of course, no message on join
-       print(
-           f"Cool, {clt_data.ip_as_str} joined!"
-       )  # ip_as_str holds the IP address of the client in a "ip:port" format
-
-   while not server.closed:
-       server.run()
-
-*Now*, if we run the client on this updated server, we will see the IP address of
-the client!
-
-Of course, this is still not that interesting on the client side, so we'll finally
-start to send some data in the next part!
-
-Sending data to our client
---------------------------
-
-Obviously, if we don't have a way of sending data, there isn't any use of hisock. ``hisock.server``
-provides the ``.send_client()``, ``.send_all_clients()``, and ``.send_client_raw()`` methods
-to send data to a specific client. **With the exception of** ``send_client_raw()``, the methods
-usually need the client to send to, command to associate the data, and the data itself.
-
-.. note::
-   Right, I've mentioned about *commands* a lot in this tutorial, but haven't really explained what it is.
-   To clean up code structure, hisock divides the data receiving part with decorators;
-   refer to :ref:`clearstuff` for more details.
-
-   Anyways, we got our organized data receiving, but now, how do we actually receive the data? Well,
-   ``hisock`` data **usually** have a command before them, so that hisock can know which data
-   should be sent to which function (as you will see later on, the commands on data **aren't** required)
-
-   We will be discussing more in-depth about what :meth:`send_all_clients()` and
-   :meth:`send_client_raw()` does, but we shall focus on :meth:`send_client()` for now
-
-So, about :meth:`send_client()`: This method of :class:`HiSockServer` is used to...
-send data to a specific client. It accepts 3 arguments: the client (we'll be using its IP in this case),
-the command, and the data. The client's IP can either be in the form "IP.IP.IP.IP:Port" as a string,
-**OR** as a two-element tuple, like ("IP.IP.IP.IP", Port). We'll be using the latter one in this case.
-
-Remember: **The data must either be a bytes-like object (E.g b"sussy"), or a dictionary (E.g {"sus": "amogus"})**
-
-Let's say that we as soon as a client joins, the server should pick a random integer from 1 to 10000, and
-send it back to the client. This is perfectly doable, and is pretty straightforward! Our server code would be:
-
-.. code-block:: python
-
-   # Server
-   import random
-   ...
-   server = ...
-
-   @server.on("join")
-   def clt_join(clt_data):  # Of course, no message on join
-       print(
-           f"Cool, {clt_data.ip_as_str} joined!"
-       )  # ip_as_str holds the IP address of the client in a "ip:port" format
-       randnum = random.randint(1, 10000)
-       server.send_client(clt_data, "random", str(randnum).encode())
-
-   ...
-
-While we sent the data to the client, the client still has no way of interpreting this new data!
-So, we must modify our client
-
-.. code-block:: python
-
-   # Client
-   client = ...
-
-   @client.on("random")
-   def interpret_randnum(msg):
-       randnum = int(msg)
-       print(f"Random number generated by the server is a {randnum}!")
-
-   ...
-
-Now, whenever the client joins that server, it will receive the data sent by it! How cool is that?
-
-Sending data to our server
---------------------------
-
-By common sense, HiSockClients provide a way to send data to the server, with
-:meth:`send()` and :meth:`raw_send()`. Again, **with the exception of** :meth:`raw_send`,
-the send methods accept two arguments; the first being the command of the data,
-and the second being the data itself.
-
-Like :class:`HiSockServer`, **The data must be a bytes-like object or a dictionary**
-
-Now, let's say that after the client got its random number, we want to
-send to ther server a message saying, hey, we received it, good for you. We could edit our client like:
-
-.. code-block:: python
-
-   # Client
-   client = ...
-
-   @client.on("random")
-   def interpret_randnum(msg):
-       randnum = int(msg)
-       print(f"Random number generated by the server is a {randnum}!")
-       client.send("verif", b"I GOT IT")
-
-   ...
-
-and our server can be
-
-.. code-block:: python
-
-   # Server
-   server = ...
-
-   @server.on("join")
-   def clt_join(clt_data):  # Of course, no message on join
-       print(
-           f"Cool, {clt_data.ip_as_str} joined!"
-       )  # ip_as_str holds the IP address of the client in a "ip:port" format
-       randnum = random.randint(1, 10000)
-       server.send_client(clt_data, "random", str(randnum).encode())
-
-   @server.on("verif")
-   def verif_msg(clt_data, message):
-       print(f"Successfully sent the number to {clt_data.ip_as_str}!")
-
-   ...
-
-We've successfully made a functional client and server!
-
-Conclusion
-----------
-
-This wraps up the basics of ``hisock``, but
-there is a lot more to know! If you are interested, I highly recommend
-you to follow the **Intermediate
-Tutorial** (Still not created yet kek), where I'll be covering some less beginner-friendly
-features of ``hisock``. See you soon!
-
-.. note::
-   While you *can* create some basic applications with some basic knowledge of ``hisock``,
-   but for larger, more robust applications, it is not recommended, but **necessary** to
-   have a better understanding of it.
-
-   Refer to the :doc:`Tutorials Page <../tutorials/index>` for more tutorials
diff --git a/docs/source/quickstart/examples.rst b/docs/source/quickstart/examples.rst
deleted file mode 100644
index 66872e2..0000000
--- a/docs/source/quickstart/examples.rst
+++ /dev/null
@@ -1,114 +0,0 @@
-Examples
-========
-
-Examples are located in the `./examples` directory in hisock.
-You can take a look at some of them. The basic client-server examples are:
-
-**Client**
-
-.. code-block:: python
-
-    """
-    Basic example of the structure of `hisock`. This is the client script.
-    Not an advanced example, but gets the main advantages of hisock across
-    """
-
-    import time
-
-    from hisock import connect, get_local_ip
-
-    server_to_connect = input("Enter server IP to connect to (Press enter for default of your local IP): ")
-    port = input("Enter Server port number (Press enter for default of 6969): ")
-
-    if server_to_connect == '':
-        server_to_connect = get_local_ip()
-
-    if port == '':
-        port = 6969
-    else:
-        port = int(port)
-
-    name = input("Name? (Press enter for no name) ")
-    group = input("Group? (Press enter for no group) ")
-
-    print("======================================= ESTABLISHING CONNECTION =======================================")
-
-    if name == '':
-        name = None
-    if group == '':
-        group = None
-
-    join_time = time.time()
-
-    s = connect(
-        (server_to_connect, port),
-        name=name, group=group)
-
-    @s.on("hello_message")
-    def handle_hello(msg):
-        print("Thanks, server, for sending a hello, just for me!")
-        print(f"Looks like, the message was sent on timestamp {msg.decode()}, "
-              f"which is just {round(float(msg.decode()) - join_time, 6) * 1000} milliseconds since the connection!")
-        print("In response, I'm going to send the server a request to do some processing")
-
-        s.send("processing1", b"randnum**2")
-        result = int(s.recv_raw())
-
-        print(f"WHOAAA! The result is {result}! Thanks server!")
-
-    while not s.closed:
-        s.update()
-
-**Server**
-
-.. code-block:: python
-
-    import sys
-    import time
-    import random
-
-    from hisock import start_server, get_local_ip, iptup_to_str
-
-    ADDR = get_local_ip()
-    PORT = 6969
-
-    if len(sys.argv) == 2:
-        ADDR = sys.argv[1]
-    if len(sys.argv) == 3:
-        PORT = int(sys.argv[2])
-
-    print(f"Serving at {ADDR}")
-    server = start_server((ADDR, PORT))
-
-    @server.on("join")
-    def client_join(client_data):
-        print(f"Cool, {':'.join(map(str, client_data['ip']))} joined!")
-        if client_data['name'] is not None:
-            print(f"    - With a sick name \"{client_data['name']}\", very cool!")
-        if client_data['group'] is not None:
-            print(f"    - In a sick group \"{client_data['group']}\", cool!")
-
-        print("I'm gonna send them a quick hello message")
-
-        server.send_client(client_data['ip'], "hello_message", str(time.time()).encode())
-
-
-    @server.on("processing1")
-    def process(client, process_request: str):
-        print(f"\nAlright, looks like {iptup_to_str(client['ip'])} received the hello message, "
-              "\nas now they're trying to compute something on the server, because they have "
-              "potato computers")
-        print("Their processing request is:", process_request)
-
-        for _ in range(process_request.count("randnum")):
-            randnum = str(random.randint(1, 100000000))
-            process_request = process_request.replace("randnum", randnum, 1)
-
-        result = eval(process_request)  # Insecure, but I'm lazy, so...
-        print(f"Cool! The result is {result}! I'mma send it to the client")
-        server.send_client_raw(client['ip'], str(result).encode())
-
-
-    while not server.closed:
-        server.run()
-
diff --git a/docs/source/quickstart/index.rst b/docs/source/quickstart/index.rst
index 3d385af..22ea864 100644
--- a/docs/source/quickstart/index.rst
+++ b/docs/source/quickstart/index.rst
@@ -1,11 +1,11 @@
 Quickstart
 ==========
 
-These pages provide you with information to start your hisock journey!
+These pages provide you with information to start your HiSock journey!
+
+Follow the :doc:`/tutorials/beginner_tutorial` for a basic tutorial on how to use HiSock.
 
 .. toctree::
 
    understanding_hisock
-   installation
-   Beginner Tutorial <beginner_tutorial>
-   examples
+   installation
\ No newline at end of file
diff --git a/docs/source/quickstart/installation.rst b/docs/source/quickstart/installation.rst
index baa918c..8afdec5 100644
--- a/docs/source/quickstart/installation.rst
+++ b/docs/source/quickstart/installation.rst
@@ -1,22 +1,19 @@
 Installation
 ============
 
-To install hisock, you will need to make sure you have PIP and Python3.7 or later installed and added to your PATH (ensure ``pip`` and ``python`` works).
-There are two main ways to install HiSock. You can install it through `PyPi <https://pypi.org/project/hisock/>`_ (recommended) or you can install it from the GitHub.
+To install :mod:`HiSock`, you will need to make sure you have PIP and Python3.7 or later installed and added to your PATH (ensure ``pip`` and ``python`` works).
+There are two main ways to install :mod:`HiSock`. You can install it through `PyPi <https://pypi.org/project/hisock/>`_ (recommended) or you can install it from the GitHub.
 
 .. warning::
-    hisock is the first project I ever published to PyPI, so there might be some
-    quirks on PyPI here and there, like the sudden burst of version post-releases.
-    However, I will try to keep this at the bare minimum, and hopefully figure out
-    PyPI good enough
+    :mod:`HiSock` is the first project I ever published to PyPI, so there might be some quirks on PyPI here and there, like the sudden burst of version post-releases. However, I will try to keep this at the bare minimum, and hopefully figure out PyPI good enough.
 
 Installing via PyPI
 -------------------
 
-The recommended way to install a stable version of hisock is through PyPI. To install hisock, open a terminal/command prompt and type:
+The recommended way to install a stable version of :mod:`HiSock` is through PyPI. To install hisock, open a terminal/command prompt and type:
 
 .. code-block:: console
-   
+
     $ python -m pip install hisock (Windows)
         OR
     $ pip install hisock (Windows)
@@ -25,12 +22,12 @@ The recommended way to install a stable version of hisock is through PyPI. To in
         OR
     $ pip3 install hisock (Mac/Linux)
 
-You've now successfully installed a stable version of hisock!
+You've now successfully installed a stable version of :mod:`HiSock`!
 
 Installing via GitHub
 ---------------------
 
-Sometimes, however, you might want to install the *latest* version of hisock, not just the *stable* version. To do this, you can either download the repository from GitHub `here <https://github.com/SSS-Says-Snek/hisock/>`_ or you can clone the repository (recommended) via Git.
+Sometimes, however, you might want to install the *latest* version of :mod:`HiSock`, not just the *stable* version. To do this, you can either download the repository from GitHub `here <https://github.com/SSS-Says-Snek/hisock/>`_ or you can clone the repository (recommended) via Git.
 
 To install from Git, open a terminal/command prompt and type:
 
@@ -40,7 +37,7 @@ To install from Git, open a terminal/command prompt and type:
         OR
     $ gh repo clone SSS-Says-Snek/hisock (GitHub CLI)
 
-Or, you can go onto GitHub and download the the ZIP file by pressing the green "Code" button, then clicking "Download ZIP". You can then unzip the file into a folder with your favorite unzip tool.
+Or, you can go onto GitHub and download the ZIP file by pressing the green "Code" button, then clicking "Download ZIP". You can then unzip the file into a folder with your favorite unzip tool.
 
 Then, go back to the terminal or command prompt and type:
 
@@ -54,14 +51,13 @@ After you're now in the working directory of the repo, install it in editable mo
 
     $ pip install -e .
 
-You should now have successfully installed the latest version of hisock! 
-If this doesn't work, then try one of the alternatives in  Method 1, but replace ``hisock`` with ``-e .`` (E.g ``python3 -m pip install -e .``)
+You should now have successfully installed the latest version of :mod:`HiSock`!
+If this doesn't work, then try one of the alternatives in Method 1, but replace ``hisock`` with ``-e .`` (E.g ``python3 -m pip install -e .``)
 
 .. note::
-   
-   If you want to check if hisock is *actually* installed, run this command in your terminal or command prompt:
+
+   If you want to check if :mod:`HiSock` is *actually* installed, run this command in your terminal or command prompt:
 
    .. code-block:: console
-       
-       $ python -c $'try:\n\timport hisock;print(f"Hisock {hisock.__version__} successfully installed")\nexcept Exception as e:print(f"Failed to install hisock for {e} reason")'
 
+       $ python -c $'try:\n\timport hisock;print(f"Hisock {hisock.constants.__version__} successfully installed")\nexcept Exception as e:print(f"Failed to install hisock for {e} reason")'
diff --git a/docs/source/quickstart/understanding_hisock.rst b/docs/source/quickstart/understanding_hisock.rst
index 27af20b..dc8d79c 100644
--- a/docs/source/quickstart/understanding_hisock.rst
+++ b/docs/source/quickstart/understanding_hisock.rst
@@ -1,45 +1,39 @@
-Understanding hisock
-====================
+Understanding :mod:`HiSock`
+===========================
 
-First of all, what... **IS** hisock? Well, it's
-a higher-level extension of the :mod:`socket` module, with simpler and more efficient usages, sure,
-but for beginners, it may be a bit confusing.
+.. contents:: Table of Contents
+   :depth: 2
+   :local:
+   :class: this-will-duplicate-information-and-it-is-still-useful-here
 
-What is the socket module?
---------------------------
+What is :mod:`HiSock`? What is it not?
+--------------------------------------
 
-I've mentioned a lot about a :mod:`socket` module in Python a lot, but some of y'all may not know
-*what* socket is. Basically, it's a pretty low-level networking interface that
-uses "sockets" to communicate between computers over a network. The problem is,
-it's a bit overwhelming when you start learning sockets.
+The TL;DR of this all is:
 
-So, I developed :mod:`hisock`, which basically simplifies :mod:`socket` down, and provides additional features.
+:mod:`HiSock` is a higher-level extension of the socket Python module with simpler and more efficient uses.
 
-What are the advantages of using hisock over socket?
+:mod:`HiSock` is not a replacement for sockets nor a new network protocol. It doesn't work through a "request/response" method.
+
+Now, if you're new to this, most likely you will have no idea what any of this means. But, that's okay! The goal of this section is to help you understand.
+
+What is the :mod:`socket` module? How does it relate to :mod:`HiSock`?
+----------------------------------------------------------------------
+
+Let's focus on what :mod:`HiSock` is built on, :mod:`socket`.
+
+:mod:`socket` is a low-level networking interface that uses "sockets" to communicate between computers over a network. In sockets, when you send data, you need to send a "header" first. Before you even send your data, you need to send some other data telling how long that data is. Along with that, :mod:`socket` is pretty bare bone. So, I developed :mod:`HiSock`, which simplifies :mod:`socket` down and provides additional features.
+
+:mod:`HiSock` focuses on abstracting the complex parts of sockets so you can focus on what you actually want to do. It does this through managing headers on its own, "type-casting" everything so you don't have to convert data to bytes and back, event-driven architecture with decorators, threading, data streams, and more. Basically, it's everything :mod:`socket` can do, minus the boilerplate code you have to write every time.
+
+How do you send and receive data with :mod:`HiSock`?
 ----------------------------------------------------
 
-That's a good question. While :mod:`hisock` is still under development, it aims
-to simplify or eliminate some complex parts of the standard :mod:`socket`. For example,
-hisock uses decorators to simplify code structure, and eliminates the hassle
-of worrying about headers.
-
-.. note::
-   Again, some of you may not know what a header is. When you send data, it is not interpreted
-   as a "message"; instead of messages, there is a "stream" of data, and the client/server decides
-   how many bytes to read from the stream. This creates a problem; how do we know how much of a message
-   to read? This is where headers come in. They are basically data the specifies the length of a "message".
-   In order for this to work, headers **MUST** be fixed-length, so it is usually padded with spaces.
-
-   Let's say that I decided that my header length would be 16 bytes long. When a client sends me
-   some data, it will have that header in front, then the actual content. I would first receive
-   the first 16 bytes, and see that it is the number "12", followed by 14 spaces. At this point,
-   I know that the "message" is 12 bytes long. So, I receive another 12 bytes, to get the message
-   "Hello World!"
-
-How do you send and received data with hisock?
------------------------------------------------------
-
-Data sent with :mod:`hisock` usually has a *command* before the data
-(but before the header, of course). Once the command is sent,
-it can be received using decorators ``hisock.server.on`` and ``hisock.client.on``.
-An argument will be passed in those decorators, and
\ No newline at end of file
+As :mod:`HiSock` is an event-driven module, data sent must have a *command* (also known as *event*) before the data (not required). An example of this could be if the command is ``send_number`` and the data is ``847``.
+
+But what data can be sent? Sockets work with bytes, but most times, you don't just want bytes. As touched on earlier, :mod:`HiSock` comes with a type-cast system, which will convert stuff to bytes under-the-hood, so you can focus on sending the data you want and not having to deal with converting it yourself.
+
+Conclusion
+----------
+
+Now that you understand *what* :mod:`HiSock` is and why it exists a little better, you can finally start to the :doc:`/tutorials/index`!
\ No newline at end of file
diff --git a/docs/source/tutorials/beginner_tutorial.rst b/docs/source/tutorials/beginner_tutorial.rst
new file mode 100644
index 0000000..9681f3c
--- /dev/null
+++ b/docs/source/tutorials/beginner_tutorial.rst
@@ -0,0 +1,360 @@
+Beginner Tutorial
+=================
+
+.. contents:: Table of Contents
+   :depth: 2
+   :local:
+   :class: this-will-duplicate-information-and-it-is-still-useful-here
+
+Before you start
+----------------
+
+It is highly encouraged that you read the :doc:`/quickstart/understanding_hisock` section before reading the tutorial.
+
+This tutorial also relies on you having basic networking knowledge (client/server, IP addresses, ports, etc.) and at least basic Python knowledge.
+
+Lastly, if you don't have :mod:`HiSock` installed, do that now! Read :doc:`/quickstart/installation`.
+
+This tutorial will focus on:
+
+- Creating a server
+- Creating a client
+- Sending and receiving data
+- Acting upon the data
+
+.. note::
+   For this tutorial, I will be referring the IP addresses as ``hisock.utils.get_local_ip()``. In reality, you will most likely use a hard-coded IP address from a user input.
+
+.. note::
+   When I refer to "a way to identify the client", I am talking about either:
+
+   - a tuple of the IP address and port of the client or
+   - a string of the client's name (this will have ambiguity if multiple clients share the same name)
+
+.. note::
+   There are a few terms that are used interchangeably here.
+
+   - Message, content, and data mean the same thing.
+   - Command and event mean the same thing.
+
+Now, without further ado, let's begin!
+
+----
+
+Creating our first server
+-------------------------
+
+In :mod:`HiSock`, there exists a class, :class:`HiSockServer`, in the ``server`` module. To create a server, you will need to create an instance of this class. The ``__init__`` function of the :class:`HiSockServer` class takes a required tuple parameter, which is the IP address as a string and port as an integer to start the server on. To find the local IP address, there is a function called :func:`utils.get_local_ip`. For the port, a number between 1024 and 65535 *should* be fine.
+
+:class:`HiSockServer` instances have a :meth:`start` method to them, which will allow the server to listen for commands and data being sent.
+
+.. code-block:: python
+
+   import hisock
+
+   server = hisock.server.HiSockServer((hisock.utils.get_local_ip(), 6969))
+
+   server.start()
+
+That's basically it! Of course, this server is useless, but hey, it's a step in the right direction! We'll add on to this later on.
+
+Obviously, without a client, a server is kind of pointless. So, let's spice things up with some client code!
+
+----
+
+Creating our first client
+-------------------------
+In :mod:`HiSock`, there is a class, :class:`HiSockClient`, in the ``client`` module. To create a client, you will need to create an instance of this class. The ``__init__`` function of the :class:`HiSockClient` class takes two required parameters. The first parameter is a tuple with the IP address and port that the server is running on. The second (optional) parameter is for the name of the client, which is used for identification. The third (optional) parameter is the group of the client, which won't be talked about in this tutorial.
+
+Like :class:`HiSockServer`, :class:`HiSockClient` needs to have its :meth:`start` method called to start the client.
+
+.. code-block:: python
+
+   import hisock
+
+   client = hisock.client.HiSockClient(
+       (hisock.utils.get_local_ip(), 6969),
+       name=input("What is your name? >")
+   )
+
+   client.start()
+
+Like the server, this doesn't do anything at all yet. Next, we will explore sending and receiving data in an example.
+
+----
+
+Transmitting Data
+-----------------
+
+Let's explore transmitting data for :mod:`HiSock`!
+
+:mod:`HiSock` is an event-driven module, and as such, has an ``on`` decorator and :meth:`send` methods for both :class:`HiSockClient` and :class:`HiSockServer`.
+
+----
+
+==============
+Receiving data
+==============
+
+.. note::
+   There is another way of receiving data, which is the :meth:`recv` method. This is not covered in this tutorial, but it is covered in the :doc:`/tutorials/intermediate_tutorial`.
+
+When a function is prefaced with the ``on`` decorator, it will run on something. It will listen for a command and run when that command is received.
+
+The ``on`` decorator takes a maximum of three parameters. One of the parameters is the command to listen on. The second (optional) parameter is whether to run the listener in its own thread or not. The third (optional) parameter is whether to override a reserved command, and this tutorial won't be covering it.
+
+For the server: The ``on`` decorator will send a maximum of two parameters to the function it is decorating (there are a few exceptions we will touch on). The first parameter is the client data. It is an instance of :class:`ClientInfo` that includes the client's name, client IP address, and the group the client is in (can be type-casted to a dict). The second parameter is the data that is being received.
+
+For the client: the ``on`` decorator will send a maximum of one parameter to the function it is decorating, which will be the message or content the client receives (in most cases).
+
+Here's an example with the ``on`` decorator in use in a server. Here, the server has a command, ``print_message_name``, and will print the message that it gets and who sent it.
+
+.. code-block:: python
+
+   server = ...
+
+   @server.on("print_message_name")
+   def on_print_message_name(client_data, message: str):
+       print(f'{client_data.name} sent "{message}"')
+
+   server.start()
+
+Here's another example with receiving data, this time on the client-side. The client will receive a command, ``greet``, with a name. It will then print out a greeting with the name.
+
+.. code-block:: python
+
+   client = ...
+
+   @client.on("greet")
+   def on_greet(name: str):
+       print(f"Hello there, {name}!")
+
+   client.start()
+
+If the ``threaded`` parameter for the ``on`` decorator is True, then the function being decorated will run in a separate thread. This allows blocking code to run while still listening for updates.
+
+It is useful if you want to get user input but also want to have the user receive other data.
+
+.. code-block:: python
+
+   client = ...
+
+   @client.on("ask_question", threaded=True)
+   def on_ask_question(question: str):
+       """Contains blocking code with ``input()``."""
+       answer = input(f"Please answer this question: {question}\n>")
+       # ... send answer to server ...
+
+   @client.on("important")
+   def on_important(message: str):
+       """This is important and cannot be missed!"""
+       ...
+
+   client.start()
+
+
+----
+
+============
+Sending data
+============
+
+:mod:`HiSock` has multiple send methods. For now, we will be talking about sending to the server from one client or to one client from the server.
+
+For the server: Sending data from the server to one client in :mod:`HiSock` uses the :meth:`send_client` method. This method takes in a maximum of three parameters. The three parameters (in order) are a way to identify the client, the command to send, and the message being sent (optional). Although we won't be talking about it here, :meth:`send_all_clients` does exactly what it says. It will do :meth:`send_client` to all the clients that are connected, and only takes in the command and optional message
+
+For the client: Sending data to the server in :mod:`HiSock` uses the :meth:`send` method. This method takes a maximum of two parameters. The first parameter is the command to send, and the second parameter is the message being sent (optional).
+
+Here is an example of sending data with a server-side code block:
+
+.. code-block:: python
+
+   server = ...
+
+   @server.on("join")
+   def on_client_join(client_data):
+       server.send_client(client_data.ip, "ask_question", "Do you like sheep?")
+
+   @server.on("question_response")
+   def on_question_response(client_data, response: str):
+       server.send_client(client_data.ip, "grade", 100)
+
+   server.start()
+
+And here is an example on the client-side:
+
+.. code-block:: python
+
+   client = ...
+
+   @client.on("ask_question")
+   def on_ask_question(question: str):
+       answer = input(f"Please answer this question: {question}\n>")
+       client.send("question_response", answer)
+
+   @client.on("grade")
+   def on_grade(grade: int):
+       print(f"You got a {grade:>3}%.")
+
+   client.start()
+
+
+----
+
+===============
+Reserved events
+===============
+
+As I stated before, not every receiver has a maximum of two parameters passed to it. Here are the cases where that is the case.
+
+:mod:`HiSock` has reserved events. These events shouldn't be sent by the client or server explicitly as it is currently unsupported.
+
+.. note::
+   Besides for ``string`` and ``bytes`` for ``message``, these reserved events do not have type casting.
+
+Here is a list of the reserved events:
+
+Server:
+
+- ``join``
+
+   The client sends the event ``join`` when they connect to the server. The only parameter sent to the function being decorated is the client data.
+- ``leave``
+
+   The client sends the event ``leave`` when they disconnect from the server. The only parameter sent to the function being decorated is the client data.
+- ``name_change``
+
+   The client sends the event ``name_change`` when they change their name. The parameters sent to the listening function are (in order) the client data, the old name, and the new name.
+- ``group_change``
+
+   The client sends the event ``group_change`` when they change their group. The parameters sent to the listening function are (in order) the client data, the old group, and the new group.
+- ``message``
+
+   When the server receives a command, it'll send an event to itself called ``message`` which will have two parameters. The two parameters are the client data who sent it and the raw data which was received.
+
+- ``*``
+
+   This will be called when there is no listener for an incoming command and data. The three parameters are the client data, the command, and the content.
+
+Client:
+
+- ``client_connect``
+
+   When a client connects to the server, all the clients will have this event called. The only parameter for this is the client data for the client which joined.
+- ``client_disconnect``
+
+   When a client disconnects from the server, all the clients will have this event called. The only parameter for this is the client data for the client which left.
+- ``force_disconnect``
+
+   The server sends the event ``force_disconnect`` to a client when they kick the client. There are *no* parameters sent with the function that is being decorated with this.
+- ``*``
+
+   This will be called when there is no listener for an incoming command and data. The two parameters are the command and the content.
+
+----
+
+============
+Type-casting
+============
+:mod:`HiSock` has a system called "type-casting" when transmitting data.
+
+Data sent and received can be one of the following types:
+
+- ``bytes``
+- ``str``
+- ``int``
+- ``float``
+- ``bool``
+- ``None``
+- ``list`` (with the types listed here)
+- ``dict`` (with the types listed here)
+
+.. note::
+   There is a type hint in ``hisock.utils`` called ``Sendable`` which has these.
+
+The type that the data gets type-casted to depends on the type hint for the message argument for the function for the event receiving the data. If there is no type hint for the argument, the data received will be bytes.
+
+Here are a few examples this server-side code block:
+
+.. code-block:: python
+
+   @server.on("string_sent")
+   def on_string_sent(client_data, message: str):
+       """``message`` will be of type ``string``"""
+       ...
+
+   @server.on("integer_sent")
+   def on_integer_sent(client_data, integer: int):
+      """``integer`` will be of type ``int``"""
+      ...
+
+   @server.on("dictionary_sent")
+   def on_dictionary_sent(client_data, dictionary: dict):
+      """``dictionary`` will be of type ``dict``"""
+      ...
+
+.. note::
+   Although these examples are on the server-side, they work the exact same for the client-side.
+
+Of course, you need to be careful that the type-casting will work. Turning ``b"hello there"`` to ``int`` will fail.
+
+----
+
+=================
+Dynamic arguments
+=================
+Remember where I said the ``on`` decorator will call the function with a *maximum* number of parameters?
+
+In :mod:`HiSock` with an _unreserved_ event, the function to handle it can be called with the maximum number of parameters *or less*. Note that in a reserved event, dynamic arguments doesn't apply.
+
+As an example, for the server: If an event has 1 argument, it will only be called with the client data. If it has 2 arguments, it will be called with the client data and the message. If it has 0 arguments, it'll be called as a void (no arguments).
+
+Data can be sent similarly. If there is no data sent, the server will receive the equivalent of ``None`` for the type-casted data.
+
+Here are a few examples of this with a server-side code block.
+
+.. code-block:: python
+
+   @server.on("event1")
+   def on_event1(client_data, message: str):
+       print(f"I have {client_data=} and {message=} as a string!")
+
+   @server.on("event2")
+   def on_event2(client_data, message: int):
+       print(f"I have {client_data=} and {message=} as an integer! {message+1=}")
+
+   @server.on("event3")
+   def on_event3(client_data):
+       print(f"I only have {client_data=}!")
+
+   @server.on("event4")
+   def on_event4():
+       print("I have nothing.")
+
+Here is an example with a client-side code block that ties into the server-side code block above:
+
+.. code-block:: python
+
+   client.send("event1", "Hello")  # Server will receive "Hello"
+   client.send("event1")  # Server will receive an empty string
+   client.send("event2", b"123")  # Server will receive 123 and output 124
+   client.send("event2")  # Server will receive 0 and output 1
+   client.send("event3", "there")  # Server won't receive "there"
+   client.send("event4", "Hi")  # Server won't receive anything
+
+
+----
+
+Conclusion
+----------
+
+Now, you know how to:
+
+- Create a server
+- Create a client
+- Transmit data
+- Work with dynamic arguments
+- Handle datatypes transmitted
+- Do stuff with the data
+
+Have fun HiSock-ing!
\ No newline at end of file
diff --git a/docs/source/tutorials/index.rst b/docs/source/tutorials/index.rst
index 30c6f3f..1cec0b9 100644
--- a/docs/source/tutorials/index.rst
+++ b/docs/source/tutorials/index.rst
@@ -1,12 +1,8 @@
-More Tutorials
+Tutorials
 --------------
 
-These tutorials are aimed at people who have just finished the
-:doc:`Quickstart Tutorial <../quickstart/tutorial>`, but want to learn more about ``hisock``.
-As I've stated in the Quickstart tutorial, it is not recommended, but necessary, to have a deeper
-understanding of :mod:`hisock` to create more complex applications.
-
 .. toctree::
    :maxdepth: 1
 
+   beginner_tutorial
    intermediate_tutorial
\ No newline at end of file
diff --git a/docs/source/tutorials/intermediate_tutorial.rst b/docs/source/tutorials/intermediate_tutorial.rst
index ba3d7a3..a44409b 100644
--- a/docs/source/tutorials/intermediate_tutorial.rst
+++ b/docs/source/tutorials/intermediate_tutorial.rst
@@ -1,65 +1,279 @@
 Intermediate-level Tutorial
 ===========================
 
-In this "tutorial", I'll talk about some more advanced topics that I *may*
-have mentioned in the quickstart, but never talked about it. These topics
-are extremely important in order to develop a complex :mod:`hisock` application.
+.. contents:: Table of Contents
+   :depth: 2
+   :local:
+   :class: this-will-duplicate-information-and-it-is-still-useful-here
 
-Without further ado, let's start!
+Before you start
+----------------
 
-Where we left off
------------------
+It is imperative that you have basic knowledge of :mod:`HiSock`. You should know everything that the beginner tutorial focuses on. If you don't know, :doc:`read that now</tutorials/beginner_tutorial>`!
 
-In the quickstart tutorial, we left off knowing how to send data from server to client,
-and client to server. While there is nothing much to the client-to-server interaction,
-with only one method ``.send()``, there are **FIVE** methods for a server-to-client
-interaction; :meth:`.send_client()`, :meth:`.send_all_clients()`,
-:meth:`.send_client_raw()`, :meth:`.send_group()`, and
-:meth:`.send_group_raw()`. While we have covered the most obvious one (:meth:`send_client()`),
-we have not yet covered the other two.
+This tutorial will focus on:
+
+- Overriding reserved events
+- How commands and data are sent
+- Threading
+- The other send methods
+- The other receive methods
+- Catch-all listeners (wildcard)
+- Groups, names, and client info
+
+----
+  
+Override
+--------
+
+In :mod:`HiSock`, there are some reserved events. However, some use-cases may want to use the same name as the reserved events for unreserved events. In order to do this, you can override the reserved events. The ``on`` decorator has an argument for overriding a command. When set to ``True``, the event will be treated like an unreserved event.
+
+.. warning::
+   This isn't recommended doing most times. Typically, the reserved events are used for handling clients. For example, if you override the join event, you'll have to have the client send a join event.
+
+Here is an example with a server-side code-block.
+
+.. code-block:: python
+   
+   server = ...
+
+   @server.on("leave", override=True)
+   def on_leave(client_data, reason: str):
+       print(f"{client_data.name} left because {reason}")
+       server.disconnect_client(client_data.ip, force=True)
+
+   server.run()
 
-Names and groups
-----------------
 
-Now, I'll clear up some more stuff before we move on. Hisock provides what I like to call
-**names** and **groups**. Under-the-hood, ``hisock`` usually identifies clients and servers
-by their **IP Address**. This works most of the times, but sometimes, you want to differentiate
-between clients, *without* knowing the IP. This is where names come in; On client connection,
-you can pass a ``name`` argument into :func:`connect`, as to bind a name to the client. Now,
-using some additional functions, we could send and receive data by using the client name!
+Now, the ``leave`` event won't be called when a client leaves. Instead, a client will manually send the ``leave`` event.
 
-.. image:: ../imgs/intermed_tut/name.png
-   :width: 400
+----
 
-Now, on to groups. Like names, groups are another way of identifying a client, but instead of **one**
-client, it can identify multiple! With groups, you can organize clients by what they correspond to. For example,
-if you are making a multiplayer game, and you are making some sort of lobby for a limited number of clients,
-then a group would be a good way to identify which clients are in which lobby.
+Threading
+---------
 
-.. image:: ../imgs/intermed_tut/group.png
-   :width: 400
+In :mod:`HiSock`, there is an option for the client or server to run entirely in a different thread. This can be useful if you want to have a server that doesn't block the main thread, such as in conjunction with Pygame or Tkinter.
+
+This is really simple to use! In place of :class:`HiSockServer` or :class:`HiSockClient`, you can use :class:`ThreadedHiSockServer` or :class:`ThreadedHiSockClient` respectively. You can also use :func:`threaded_connect` in place of :func:`connect` and :func:`start_threaded_server` in place of :func:`start_server`.
+
+----
+
+How commands and data are sent
+------------------------------
+
+Commands and data are sent in a special syntax.
 
 .. note::
-   I've actually never used groups before, which is pretty... uh... sad. I added groups because
-   I thought of some use cases for it, but I never bothered to use it in an example, so... yeah.
+   In this section, text in ``<>`` is a placeholder for data.
+
+=================
+Reserved commands
+=================
+
+For reserved commands, the syntax is different for each one.
+
+For the client:
+
+- ``$KEEPALIVE$``
+
+   This command is sent to the client from the server to make sure that the client is still connected.
+- ``$DISCONN$``
+  
+   This command is sent to the client from the server to disconnect the client.
+- ``$CLTCONN$<client info as a stringified dict>``
+
+   This command is sent to the client from the server to inform that a new client connected.
+- ``$CLTDISCONN$<client info as a stringified dict>``
+
+   This command is sent to the client from the server to inform that a client disconnected.
+
+For the server:
+
+- ``<connecting socket is same as server socket>``
+
+   This happens when a new client connects to server.
+- ``<bad client file number>`` OR ``<client data is falsy>`` OR ``$USRCLOSE$``
+  
+   This happens when the client closes the connection and emits its leave, or it encounters an error when transmitting data. The client will be disconnected.
+- ``$KEEPACK$``
+
+   This is sent to the client from the server to acknowledge that the client is still connected.
+- ``$GETCLT$<client_identifier, either a name or stringified IP>``
+
+   This is sent to the server from the client to get the client's data.
+- ``$CHNAME$<new name>`` OR ``$CHGROUP$<new group>``
+
+   This is sent to the server from the client to change the client's name or group, respectively.
+
+====
+
+===================
+Unreserved commands
+===================
+
+For reserved commands, the data is sent as follows:
+
+- ``command`` and ``message`` sent
+
+   ``$CMD$<command>$MSG$<message>``
+- ``command`` sent
+
+   ``$CMD$<command>``
+
+----
+
+The other send methods
+----------------------
+
+In :mod:`HiSock`, there are multiple send methods for the *server*. These methods are:
+
+- ``send_client`` - sends a command and/or message to a singe client
+- ``send_all_clients`` - sends a command and/or message to every client connected
+- ``send_group`` - sends a command and/or message to every client in a group
+
+There are also a few internal send methods that shouldn't need to be used. They are used for sending *raw* data. These methods are:
+
+- ``_send_client_raw``
+- ``_send_all_clients_raw``
+- ``_send_group_raw``
+
+----
+
+A new way to receive data
+-------------------------
+
+In :mod:`HiSock`, there is also a different way to receive data.
+
+Say you want to send data and wait for a response. Normally, you'd have to do something like this:
+
+.. code-block:: python
+
+   client = ...
+
+   @client.on("start")
+   def on_start():
+       client.send(input("What would you like to say?"))
+       print("Waiting for a response...")
+
+   @client.on("response")
+   def on_response(response: str):
+       print(f"The server said: {response}")
+
+   client.start()
+
+However, there is a better way! There is a method called :meth:`recv`. This method has two parameters. The parameters (in order) are the command to receive on (optional) and the type to receive as (defaults to bytes).
+
+The :meth:`recv` method works like the ``on`` decorator. If the command to receive on is not specified, :meth:`recv` will receive on any command or data that is sent and not caught by a function. Otherwise, it will only receive on the command. Then, the message received will be type-casted to the type specified and returned.
+
+:meth:`recv` is blocking, so the code in the function will pause until it's done. This is why it's recommended to use it in a threaded function.
+
+Now, let's use the example above, but using the :meth:`recv` method!
+
+.. code-block:: python
+
+   client = ...
+
+   @client.on("start", threaded=True)
+   def on_start():
+       client.send(input("What would you like to say?"))
+       print("Waiting for a response...")
+       response = client.recv("response", str)
+       print(f"The server said: {response}")
+
+   client.start()
+
+----
+
+Catch-all listeners (wildcard)
+------------------------------
+
+In :mod:`HiSock`, there is a way to catch every piece of data sent that hasn't been handled by a listener already. This amazing thing is known as the catch-all or wildcard listener. Despite the name, this is not like the ``message`` reserved listener. It will only be called when there is no other handler for the data.
+
+The function that will be called for the catch-all listener will be passed in the client data if it's a server, the command, and the message. If there is no message, it'll be type-casted into the equivalent of ``None``.
+
+Here is an example with a client-side and server-side code block:
+
+.. code-block:: python
+
+   client = ...
+
+   client.send(
+      "hello i am an uncaught command",
+      f"Random data: "
+      + "".join(
+         [
+               chr(choice((randint(65, 90), randint(97, 122))))
+               for _ in range(100)
+         ]
+      ),
+   )
+
+   @client.on("client_connect")
+   def on_connect(client_data):
+       ...
+
+   @client.on("client_disconnect")
+   def on_disconnect(client_data):
+       ...
+
+   @client.on("*")
+   def on_wildcard(command: str, data: str):
+       print(f"The server sent some uncaught data: {command=}, {data=}")
+
+   client.start()
+
+
+.. code-block:: python
+
+   server = ...
+
+   @server.on("join")
+   def on_join(client_data):
+      ...
+   
+   @server.on("leave")
+   def on_leave(client_data):
+      ...
+
+   @server.on("*")
+   def on_wildcard(client_data, command: str, data: str):
+       print(
+           f"There was some unhandled data from {client_data.name}. "
+           f"{command=}, {data=}"
+       )
+
+       server.send_client(client_data, "i am also an uncaught command", data.replace("a", "ඞ"))
+
+   server.start()
+
+
+----
+
+Groups, name, and client info
+-----------------------------
+
+In :mod:`HiSock`, each client has its own client data. Like mentioned in the previous tutorial, this client data can be a dictionary or an instance of :class:`ClientInfo`.
 
-Now, let's finally dig into the ``send`` methods!
+Client info contains the following:
 
+- ``name``
 
-The other ``send`` methods
---------------------------
+   The name of the client. Will be ``None`` if not entered.
+- ``group``
 
-In the beginner tutorial, we've covered the :meth:`.send_client()` method that allows the server to
-send data to specific clients. But, what if we wanted to send data to all the clients? Well, that
-is exactly what :meth:`.send_all_clients()` does. Yes, the name is pretty self-explanatory.
+   The group of the client. Will be ``None`` if not entered.
+- ``ip``
+  
+   The IP and port of the client as a string.
 
-Now, let's move on to :meth:`.send_client_raw()`! Sometimes, while you do want to send data to a client,
-you don't necessarily want to send it **with a command**. For example, if a server just sent out information to
-a client, and a client sent back some more information, it would be much easier to directly send data to
-the client, instead of sending a command, and needing to make another function. So, :meth:`.send_client_raw()`
-allows you to send data *without a command*.
+:class:`ClientInfo` can also act like a dictionary, so you can access the client's data like this:
 
-Remember when I said that groups can be used to organize clients? Well, how do we communicate and send
-data to a group? As you may have guessed by the name, :meth:`.send_group()` sends data to a specific group,
-taking a group name and the data as parameters.
+.. code-block:: python
+   
+   server = ...
 
+   @server.on("join")
+   def on_join(client_data):
+       ip = client_data.ip  # Normal access
+       name = client_data["name"]  # Dictionary-like access
+       group = client_data.group   #Normal access
\ No newline at end of file
diff --git a/examples/__init__.py b/examples/__init__.py
index 5a6ef04..b1a1043 100644
--- a/examples/__init__.py
+++ b/examples/__init__.py
@@ -1,4 +1,13 @@
+"""
+This python file helps to import all the hisock example files
+
+====================================
+Copyright SSS_Says_Snek, 2021-present
+====================================
+"""
+
 import examples.basic
 import examples.tictactoe
 
+# Prevent "recursive" imports
 del examples
diff --git a/examples/basic/example_client.py b/examples/basic/example_client.py
index cad4c11..0f4b26c 100644
--- a/examples/basic/example_client.py
+++ b/examples/basic/example_client.py
@@ -10,7 +10,6 @@
 
 
 def run():
-    # Parse `input` results
     server_to_connect = input(
         "Enter server IP to connect to (Press enter for default of your local IP): "
     )
@@ -36,29 +35,26 @@ def run():
     if group == "":
         group = None
 
+    client = connect((server_to_connect, port), name=name, group=group)
     join_time = time.time()
 
-    # Establishes connection to the server
-    s = connect((server_to_connect, port), name=name, group=group)
-
-    @s.on("hello_message")
-    def handle_hello(msg):
+    @client.on("hello_message")
+    def handle_hello(msg: str):
         print("Thanks, server, for sending a hello, just for me!")
         print(
-            f"Looks like, the message was sent on timestamp {msg.decode()}, "
-            f"which is just {round(float(msg.decode()) - join_time, 6) * 1000} milliseconds since the connection!"
+            f"Looks like, the message was sent on timestamp {msg}, "
+            f"which is just {round(float(msg) - join_time, 6) * 1000} milliseconds since the connection!"
         )
         print(
             "In response, I'm going to send the server a request to do some processing"
         )
 
-        s.send("processing1", b"randnum**2")
-        result = int(s.recv_raw())
+        client.send("processing1", b"randnum**2")
+        result = client.recv("something", int)
 
         print(f"WHOAAA! The result is {result}! Thanks server!")
 
-    while not s.closed:
-        s.update()
+    client.start()
 
 
 if __name__ == "__main__":
diff --git a/examples/basic/example_server.py b/examples/basic/example_server.py
index 8af948c..d0634d4 100644
--- a/examples/basic/example_server.py
+++ b/examples/basic/example_server.py
@@ -8,7 +8,7 @@
 import time
 import random
 
-from hisock import start_server, get_local_ip, iptup_to_str
+from hisock import start_server, get_local_ip
 
 
 def run():
@@ -21,42 +21,26 @@ def run():
         PORT = int(sys.argv[2])
 
     print(f"Serving at {ADDR}")
-    server = start_server((ADDR, PORT))  # Starts the server
+    server = start_server((ADDR, PORT))
 
-    # `join` is a reserved function - It will activate
-    # whenever a new client joins
     @server.on("join")
     def client_join(client_data):
-        # The `join` reserved function returns one argument;
-        # The client data, with IP, name, and group
-        # (Names and Groups are just an easy way to identify clients without IP)
-        print(f"Cool, {':'.join(map(str, client_data['ip']))} joined!")
+        print(f"Cool, {client_data.ip_as_str} joined!")
         if client_data["name"] is not None:
-            print(f"    - With a sick name \"{client_data['name']}\", very cool!")
+            print(f'    - With a sick name "{client_data.name}", very cool!')
         if client_data["group"] is not None:
-            print(f"    - In a sick group \"{client_data['group']}\", cool!")
+            print(f'    - In a sick group "{client_data.group}", cool!')
 
         print("I'm gonna send them a quick hello message")
 
-        # The current way to send; it takes in a tuple of IP,
-        # The command to send (essentially what to label the message as),
-        # And the content (Content must be in bytes for now, str support may be added)
         server.send_client(
             client_data["ip"], "hello_message", str(time.time()).encode()
         )
 
-    # Client SHOULD send a command `processing1`, soon after the server message
-    # This will activate whenever a client sends a `processing1` command
     @server.on("processing1")
-    def process(client, process_request: str):
-        # Nonreserved functions take two arguments; the client data,
-        # And the actual content
-        # In HiSock, you are able to type cast the initially `bytes` content
-        # Into another supported type, like str, int, or float
-
-        # `iptup_to_str` converts a tuple IP into a string IP ('1.1.1.1', 4) -> 1.1.1.1:4
+    def process(client_data, process_request: str):
         print(
-            f"\nAlright, looks like {iptup_to_str(client['ip'])} received the hello message, "
+            f"\nAlright, looks like {client_data.ip_as_str} received the hello message, "
             "\nas now they're trying to compute something on the server, because they have "
             "potato computers"
         )
@@ -69,14 +53,9 @@ def process(client, process_request: str):
         result = eval(process_request)  # Insecure, but I'm lazy, so...
         print(f"Cool! The result is {result}! I'mma send it to the client")
 
-        # Whenever you want to send multiple content rounds without
-        # creating 500 commands, use `send_client_raw`. Along with
-        # `recv_raw`, it can be used to send multiple content rounds.
-        # Here, we are sending the result back to the client
-        server.send_client_raw(client["ip"], str(result).encode())
+        server.send_client(client_data, "something", str(result))
 
-    while not server.closed:
-        server.run()
+    server.start()
 
 
 if __name__ == "__main__":
diff --git a/examples/connectfour/ThaleahFat.ttf b/examples/connectfour/ThaleahFat.ttf
new file mode 100644
index 0000000..91fc7dc
Binary files /dev/null and b/examples/connectfour/ThaleahFat.ttf differ
diff --git a/examples/connectfour/_shared.py b/examples/connectfour/_shared.py
new file mode 100644
index 0000000..5c8005b
--- /dev/null
+++ b/examples/connectfour/_shared.py
@@ -0,0 +1,60 @@
+from __future__ import annotations
+
+
+PIECE_COLORS = {
+    -1: (50, 50, 50),  # NO_PIECE
+    0: (255, 0, 0),  # RED
+    1: (255, 255, 0),  # YELLOW
+    2: (120, 0, 0),  # HOVER RED
+    3: (120, 120, 0),  # HOVER YELLOW
+}
+
+
+class BoardEnum:
+    NO_PIECE = -1
+    RED = 0
+    YELLOW = 1
+    HOVER_RED = 2
+    HOVER_YELLOW = 3
+
+
+class Board:
+    def __init__(self):
+        self.board: list[list[int]] = [
+            [BoardEnum.NO_PIECE for _ in range(7)] for _ in range(6)
+        ]
+        self.win_vectors = (
+            (0, -1),
+            (1, -1),
+            (1, 0),
+            (1, 1),
+            (0, 1),
+            (-1, 1),
+            (-1, 0),
+            (-1, -1),
+        )
+        self.total_moves = 0
+
+    def player_win(self, new_pos: tuple[int, int]) -> bool:
+        potential_paths = []
+
+        # Calculate potential win paths
+        for win_vector in self.win_vectors:
+            potential_path = []
+            for i in range(4):
+                new_coord = [
+                    new_pos[0] + win_vector[0] * i,
+                    new_pos[1] + win_vector[1] * i,
+                ]
+                if (not 0 <= new_coord[0] <= 6) or (not 0 <= new_coord[1] <= 5):
+                    break
+                potential_path.append(self.board[new_coord[1]][new_coord[0]])
+            potential_paths.append(potential_path)
+
+        for path in potential_paths:
+            if path.count(self.board[new_pos[1]][new_pos[0]]) == 4:
+                return True
+        return False
+
+    def make_move(self, piece_type, x, y):
+        self.board[y][x] = piece_type
diff --git a/examples/connectfour/connectfour_client.py b/examples/connectfour/connectfour_client.py
new file mode 100644
index 0000000..727172c
--- /dev/null
+++ b/examples/connectfour/connectfour_client.py
@@ -0,0 +1,420 @@
+import abc
+import string
+import time
+
+import hisock
+import _shared as shared
+
+import pygame
+import pygame.gfxdraw
+import pygame_gui
+
+pygame.init()
+
+WIDTH, HEIGHT = 800, 600
+
+running = True
+
+screen = pygame.display.set_mode((WIDTH, HEIGHT))
+clock = pygame.time.Clock()
+
+pygame.display.set_caption("HiSock Connect Four")
+
+
+class FontCache:
+    font_cache = {}
+
+
+class Button:
+    def __init__(
+        self,
+        rect,
+        color,
+        txt,
+        txt_color,
+        font_size,
+        hover_color=None,
+        center=False,
+        func_when_clicked=None,
+    ):
+        self.rect = rect
+        self.color = color
+        self.txt = txt
+        self.txt_color = txt_color
+
+        if font_size in FontCache.font_cache:
+            self.font = FontCache.font_cache[font_size]
+        else:
+            self.font = pygame.font.Font("ThaleahFat.ttf", font_size)
+            FontCache.font_cache[font_size] = self.font
+
+        self.txt_surf = self.font.render(txt, True, txt_color)
+        self.func_when_clicked = func_when_clicked
+
+        if hover_color is None:
+            self.hover_color = color
+        else:
+            self.hover_color = hover_color
+        if center:
+            self.rect.center = (rect.x, rect.y)
+
+    def draw(self):
+        if self.rect.collidepoint(pygame.mouse.get_pos()):
+            pygame.draw.rect(screen, self.hover_color, self.rect)
+        else:
+            pygame.draw.rect(screen, self.color, self.rect)
+
+        screen.blit(
+            self.txt_surf,
+            (
+                self.rect.centerx - self.txt_surf.get_width() // 2,
+                self.rect.centery - self.txt_surf.get_height() // 2,
+            ),
+        )
+
+    def handle_event(self, event):
+        if (
+            event.type == pygame.MOUSEBUTTONDOWN
+            and self.rect.collidepoint(event.pos)
+            and self.func_when_clicked
+        ):
+            self.func_when_clicked()
+
+
+class BaseState(abc.ABC):
+    def __init__(self):
+        self.fontname = "ThaleahFat.ttf"
+
+    def draw(self):
+        pass
+
+    def handle_event(self, event):
+        pass
+
+    def blit_text(self, text, pos, size, color, center=False):
+        if size not in FontCache.font_cache:
+            font = pygame.font.Font(self.fontname, size)
+            FontCache.font_cache[size] = font
+        else:
+            font = FontCache.font_cache[size]
+
+        surf = font.render(text, True, color)
+        if center:
+            surf_rect = surf.get_rect(center=pos)
+        else:
+            surf_rect = surf.get_rect(topleft=pos)
+
+        screen.blit(surf, surf_rect)
+
+
+class ConnectState(BaseState):
+    def __init__(self):
+        super().__init__()
+
+        self.gui_manager = pygame_gui.UIManager((WIDTH, HEIGHT))
+
+        self.username_input_rect = pygame.Rect(0, 0, WIDTH * 0.9, 75)
+        self.username_input_rect.center = (WIDTH // 2, 100)
+        self.username_input = pygame_gui.elements.ui_text_entry_line.UITextEntryLine(
+            relative_rect=self.username_input_rect, manager=self.gui_manager
+        )
+
+        self.server_input_rect = pygame.Rect(0, 0, WIDTH * 0.9, 75)
+        self.server_input_rect.center = (WIDTH // 2, 350)
+        self.server_input = pygame_gui.elements.ui_text_entry_line.UITextEntryLine(
+            relative_rect=self.server_input_rect, manager=self.gui_manager
+        )
+
+        self.conn_button = Button(
+            pygame.Rect(WIDTH // 2, 500, 200, 50),
+            (0, 128, 0),
+            "Connect",
+            (0, 0, 0),
+            30,
+            hover_color=(0, 170, 0),
+            center=True,
+            func_when_clicked=self.connect_to_server,
+        )
+
+        self.txt_inputs = (self.username_input, self.server_input)
+        allowed_chars = list(string.ascii_letters + string.digits + "_")
+
+        self.username_input.set_allowed_characters(allowed_chars)
+        self.username_input.set_text_length_limit(20)
+        self.server_input.set_allowed_characters(allowed_chars + [".", ":"])
+
+    def connect_to_server(self):
+        ip, port = hisock.utils.ipstr_to_tup(self.server_input.text)
+        name = self.username_input.text
+
+        Data.client = hisock.ThreadedHiSockClient((ip, port), name=name)
+        Data.current_state = GameState()
+
+    def handle_event(self, event):
+        self.username_input.process_event(event)
+        self.server_input.process_event(event)
+
+        self.conn_button.handle_event(event)
+
+        if event.type == pygame.MOUSEBUTTONDOWN:
+            for i, txt_input in enumerate(self.txt_inputs):
+                if txt_input.relative_rect.collidepoint(*event.pos):
+                    txt_input.focus()
+                    self.txt_inputs[int(not i)].unfocus()
+                else:
+                    txt_input.unfocus()
+
+    def draw(self):
+        self.blit_text(
+            "Enter username:", (WIDTH // 2, 40), 48, (255, 255, 255), center=True
+        )
+        self.blit_text(
+            "Enter server IP:", (WIDTH // 2, 290), 48, (255, 255, 255), center=True
+        )
+
+        self.conn_button.draw()
+
+        self.gui_manager.update(Data.deltatime)
+        self.gui_manager.draw_ui(screen)
+
+
+class GameState(BaseState):
+    def __init__(self):
+        super().__init__()
+
+        self.client = Data.client
+        self.paired = False
+        self.pairing_ticks = pygame.time.get_ticks()
+        self.load_circles_loops = 0
+        self.load_circles_idx = 0
+
+        self.is_turn = False
+        self.opponent_name = None
+        self.arrow_column = None
+        self.piece_type = None
+        self.hover_piece_type = None
+        self.hover_piece_idx = None
+        self.turn_no = 1
+        self.start_time = None
+        self.game_status = "in_progress"
+
+        self.board = shared.Board()
+
+        self.down_arrow = pygame.image.load("downarrow.png").convert_alpha()
+
+        self.board_rect = pygame.Rect(0, 0, 585, 445)
+        self.board_rect.bottomleft = (30, 580)
+
+        @self.client.on("start")
+        def on_start(data: dict):
+            print(data, type(data))
+
+            self.paired = True
+            self.is_turn = True if data["turn"] == "first" else False
+            self.piece_type = (
+                shared.BoardEnum.RED if self.is_turn else shared.BoardEnum.YELLOW
+            )
+            self.hover_piece_type = (
+                shared.BoardEnum.HOVER_RED
+                if self.is_turn
+                else shared.BoardEnum.HOVER_YELLOW
+            )
+            self.opponent_name = data["opp_name"]
+            self.start_time = time.time()
+
+        @self.client.on("new_move")
+        def on_new_move(data: dict):
+            x, y = data["opp_move"][0], data["opp_move"][1]
+
+            # self.turn_no = data["turn"]
+            self.board.board[y][x] = data["opp_piece"]
+            self.is_turn = True
+
+        @self.client.on("new_turn")  # JUST SYNCS THE TURN COUNTER, NOTHING ELSE!!!
+        def on_new_turn(turn_no: int):
+            self.turn_no = turn_no
+
+        @self.client.on("win")
+        def on_win():
+            self.game_status = "win"
+            print("You win!!!!")
+
+        @self.client.on("lose")
+        def on_lose():
+            self.game_status = "lose"
+            print("You lose :(((")
+
+        @self.client.on("disconn")
+        def on_disconn(reason: str):
+            print(reason)
+
+        self.client.start()
+
+    @staticmethod
+    def format_secs(secs):
+        return f"{(secs // 60):02d}:{(secs % 60):02d}"
+
+    @staticmethod
+    def update_piece(x, y, piece):
+        pygame.gfxdraw.aacircle(
+            screen, 80 + 80 * x, 180 + 70 * y, 30, shared.PIECE_COLORS[piece]
+        )
+        pygame.gfxdraw.filled_circle(
+            screen, 80 + 80 * x, 180 + 70 * y, 30, shared.PIECE_COLORS[piece]
+        )
+
+    def pos_to_coord(self, mouse_x, mouse_y):
+        x = (mouse_x - self.board_rect.x) * 7 // self.board_rect.width
+        y = (mouse_y - self.board_rect.y) * 6 // self.board_rect.height
+
+        return x, y
+
+    def draw(self):
+        if not self.paired:
+            # Waiting
+            sec_elapsed = self.load_circles_loops * 3 + self.load_circles_idx
+
+            self.blit_text(
+                "Waiting for opponent...",
+                (WIDTH // 2, 40),
+                48,
+                (255, 255, 255),
+                center=True,
+            )
+            self.blit_text(
+                f"Time elapsed: {sec_elapsed} seconds",
+                (WIDTH // 2, 95),
+                24,
+                (255, 255, 255),
+                center=True,
+            )
+
+            if pygame.time.get_ticks() - self.pairing_ticks >= 1000:
+                self.load_circles_idx += 1
+                quotient, self.load_circles_idx = divmod(self.load_circles_idx, 3)
+
+                if quotient == 1:
+                    self.load_circles_loops += 1
+                self.pairing_ticks = pygame.time.get_ticks()
+
+            for i in range(3):
+                if i != self.load_circles_idx:
+                    circ_color = (80, 80, 80)
+                else:
+                    circ_color = (128, 128, 128)
+
+                pygame.draw.circle(screen, circ_color, (250 + i * 150, 250), 50)
+        else:
+            mouse_pos = pygame.mouse.get_pos()
+
+            # Actual game draw
+            self.blit_text(
+                f"{self.client.name} (YOU)",
+                (WIDTH // 2, 10),
+                25,
+                (255, 255, 255),
+                center=True,
+            )
+            self.blit_text("VS", (WIDTH // 2, 35), 25, (255, 255, 255), center=True)
+            self.blit_text(
+                f"{self.opponent_name} (OPPONENT)",
+                (WIDTH // 2, 60),
+                25,
+                (160, 160, 160),
+                center=True,
+            )
+
+            self.blit_text(f"Turn: {self.turn_no}", (650, 130), 25, (255, 255, 255))
+            self.blit_text(
+                f"Time: {self.format_secs(int(time.time() - self.start_time))}",
+                (650, 170),
+                25,
+                (255, 255, 255),
+            )
+
+            if self.is_turn:
+                self.blit_text("YOUR TURN", (650, 500), 30, (255, 255, 255))
+            else:
+                self.blit_text("OPP TURN", (650, 500), 30, (160, 160, 160))
+
+            pygame.draw.rect(screen, (0, 110, 210), self.board_rect, border_radius=5)
+
+            for y, row in enumerate(self.board.board):
+                for x, piece in enumerate(row):
+                    # IDC about performance
+                    self.update_piece(x, y, piece)
+
+            if self.hover_piece_idx is not None:
+                self.update_piece(*self.hover_piece_idx, self.hover_piece_type)
+
+            if self.board_rect.collidepoint(mouse_pos):
+                self.arrow_column = (
+                    (mouse_pos[0] - self.board_rect.x) * 7 // self.board_rect.width
+                )
+                screen.blit(
+                    self.down_arrow,
+                    (60 + self.arrow_column * (self.board_rect.width - 20) // 7, 80),
+                )
+
+    def handle_event(self, event):
+        mp = pygame.mouse.get_pos()
+        x, _ = self.pos_to_coord(*mp)  # Y is not used cuz it's a loser
+
+        if self.board_rect.collidepoint(mp):
+            column = [row[x] for row in self.board.board]
+            hover_y = column.count(shared.BoardEnum.NO_PIECE) - 1
+            self.hover_piece_idx = [x, hover_y]
+        else:
+            self.hover_piece_idx = None
+
+        if event.type == pygame.MOUSEBUTTONDOWN and self.is_turn:  # Player turn
+            hov_x, hov_y = self.hover_piece_idx
+            if self.board.board[hov_y][hov_x] == shared.BoardEnum.NO_PIECE:
+                self.board.board[hov_y][hov_x] = self.piece_type
+
+                self.client.send(
+                    "turn_made", {"x": hov_x, "y": hov_y, "piece": self.piece_type}
+                )
+                self.is_turn = False
+
+            print(hov_x, hov_y)
+
+
+class Data:
+    current_state = ConnectState()
+    font_cache = {}
+    client = None
+    deltatime = 0
+
+    @classmethod
+    def update_deltatime(cls):
+        cls.deltatime = clock.tick(60)
+
+
+def run():
+    # client.start()
+    running = True
+
+    while running:
+
+        for event in pygame.event.get():
+            Data.current_state.handle_event(event)
+
+            if event.type == pygame.QUIT:
+                running = False
+
+        screen.fill((50, 50, 50))
+
+        Data.current_state.draw()
+        pygame.display.update()
+
+        Data.update_deltatime()
+
+    pygame.quit()
+
+    if Data.client is not None:
+        Data.client.close()
+
+
+if __name__ == "__main__":
+    run()
diff --git a/examples/connectfour/connectfour_server.py b/examples/connectfour/connectfour_server.py
new file mode 100644
index 0000000..f528747
--- /dev/null
+++ b/examples/connectfour/connectfour_server.py
@@ -0,0 +1,112 @@
+import hisock
+import _shared as shared
+import random
+
+
+class ServerData:
+    def __init__(self):
+        self.client_pairs: list[list] = []
+
+    def add_client(self, clt_data):
+        if not self.client_pairs:
+            self.client_pairs.append([clt_data, shared.Board()])
+            return
+
+        # Last element in list is always the most recently updated
+        last = self.client_pairs[-1]
+        if len(last) == 2:
+            # Only one client is connected in this pair
+            last.insert(1, clt_data)
+
+            # List now: [client_one, client_two, board]
+            goes_first = random.choice((0, 1))
+            goes_last = not goes_first
+            server.send_client(
+                last[goes_first],
+                "start",
+                {"turn": "first", "opp_name": last[goes_last].name},
+            )
+            server.send_client(
+                last[goes_last],
+                "start",
+                {"turn": "last", "opp_name": last[goes_first].name},
+            )
+        else:
+            # Both are connected; no open spots, create one
+            self.client_pairs.append([clt_data, shared.Board()])
+
+    def remove_client(self, clt_data):
+        for client_pair in self.client_pairs[:]:
+            if clt_data in client_pair:
+                if len(client_pair) == 3:
+                    other_client_idx = not client_pair.index(clt_data)
+
+                    print(
+                        f"[DEBUG] Left clt: {clt_data}, Other clt: {client_pair[other_client_idx]}\n"
+                        f"List of paired clients: {self.client_pairs}\nTime: {__import__('time').time()}"
+                    )
+
+                    other_client = client_pair[other_client_idx]
+                    self.client_pairs.remove(client_pair)
+
+                    server.send_client(
+                        client_pair[other_client_idx],
+                        "disconn",
+                        b"Opponent Disconnected",
+                    )
+
+                    server.disconnect_client(other_client, force=False)
+                else:
+                    self.client_pairs.remove(client_pair)
+                print("[DEBUG] BYE BYE CLIENTS")
+                break
+
+    def find_client(self, clt_data):
+        for client_pair in self.client_pairs:
+            if clt_data in client_pair:
+                return client_pair
+
+
+addr, port = hisock.input_server_config()
+server = hisock.HiSockServer((addr, port))
+
+data = ServerData()
+
+
+@server.on("join")
+def on_join(clt_data):
+    data.add_client(clt_data)
+    print(data.client_pairs)
+
+
+@server.on("leave")
+def on_leave(clt_data):
+    print(f"[DEBUG] in on_leave: {clt_data} LEFT, REMOVING CLIENT")
+    data.remove_client(clt_data)
+    print(data.client_pairs)
+
+
+@server.on("turn_made")
+def on_turn_made(clt_data, move_info: dict):
+    x, y = move_info["x"], move_info["y"]
+
+    client_pair = data.find_client(clt_data)
+    client_pair[2].board[y][x] = move_info["piece"]
+
+    other_client = client_pair[not client_pair.index(clt_data)]
+
+    if client_pair[2].player_win((x, y)):
+        server.send_client(clt_data, "win")
+        server.send_client(other_client, "lose")
+
+    server.send_client(
+        other_client, "new_move", {"opp_move": [x, y], "opp_piece": move_info["piece"]}
+    )
+
+    client_pair[2].total_moves += 1
+    if client_pair[2].total_moves % 2 == 0:
+        for client in (clt_data, other_client):
+            server.send_client(client, "new_turn", client_pair[2].total_moves // 2 + 1)
+
+
+server.start()
diff --git a/examples/connectfour/downarrow.png b/examples/connectfour/downarrow.png
new file mode 100644
index 0000000..33c4041
Binary files /dev/null and b/examples/connectfour/downarrow.png differ
diff --git a/examples/tictactoe/Dockerfile b/examples/tictactoe/Dockerfile
deleted file mode 100644
index c10b41f..0000000
--- a/examples/tictactoe/Dockerfile
+++ /dev/null
@@ -1,13 +0,0 @@
-# syntax=docker/dockerfile:1
-
-FROM python:3.9-slim-buster
-
-WORKDIR /serv
-
-RUN pwd
-RUN pip3 install hisock
-
-COPY . ./
-
-CMD ["tictactoe_server.py", "Default"]
-ENTRYPOINT ["python3", "-u"]
\ No newline at end of file
diff --git a/examples/tictactoe/__init__.py b/examples/tictactoe/__init__.py
deleted file mode 100644
index 0b4e2ed..0000000
--- a/examples/tictactoe/__init__.py
+++ /dev/null
@@ -1,17 +0,0 @@
-from . import tictactoe_client, tictactoe_server
-
-
-def run(client_or_serv="input"):
-    if client_or_serv == "input":
-        user_input = input("Are you connecting to or hosting a tictactoe match? (C/H) ")
-
-        if user_input.lower() == "c":
-            tictactoe_client.run()
-        elif user_input.lower() == "h":
-            tictactoe_server.run()
-        else:
-            print("No option selected, aborting...")
-    elif client_or_serv.lower() == "client":
-        tictactoe_client.run()
-    elif client_or_serv.lower() == "server":
-        tictactoe_server.run()
diff --git a/examples/tictactoe/shared.py b/examples/tictactoe/shared.py
new file mode 100644
index 0000000..b62ce22
--- /dev/null
+++ b/examples/tictactoe/shared.py
@@ -0,0 +1,101 @@
+"""HiSock TicTacToe client and server shared code"""
+
+
+### Setup ###
+import hisock
+import traceback
+from typing import Union
+
+### Functions ###
+def get_username() -> str:
+    """Asks the user for a username and returns it"""
+
+    while True:
+        username: str = input("Enter a username: ")
+
+        if username == "":
+            print("The username cannot be blank")
+            continue
+
+        if len(username) > 16:
+            print("The username must be 16 characters or less")
+            continue
+
+        if not username.strip("_").isalnum():
+            print(
+                "The username must be made up of only alpha numeric characters and underscores"
+            )
+            continue
+
+        break
+
+    return username
+
+
+def get_ip_addr_port() -> tuple[str, int]:
+    """Asks the user for an IP address and port and returns it (ip, port)"""
+
+    # IP address
+    while True:
+        ip_addr: str = input("Enter an IP address: ")
+
+        if ip_addr == "":
+            print("The IP address cannot be blank")
+            continue
+        if not ip_addr.count(".") == 3:
+            print("The IP address must be in the form of a dotted quad")
+            continue
+
+        break
+
+    # Port
+    while True:
+        port: str = input("Enter a port: ")
+
+        if not port.isnumeric():
+            print("The port must be a number")
+            continue
+
+        # The port is numberical, convert it now
+        port = int(port)
+
+        if port < 1 or port > 65535:
+            print("The port must be between 1 and 65535")
+            continue
+
+        break
+
+    return ip_addr, port
+
+
+def log_error(error: Exception):
+    """Logs an error to the console"""
+
+    print(
+        "AN ERROR HAS OCCURED!!!\n"
+        f"Error message: {error!s}\n"
+        f"Traceback:\n{traceback.format_exc()}\n"
+        "-Sheepy's Amazing Error Message Services"
+    )
+
+
+def connect_to_server(
+    ip_address: Union[str, None] = None,
+    name: Union[str, None] = None,
+    threaded: bool = False,
+) -> Union[hisock.client.HiSockClient, hisock.client.ThreadedHiSockClient]:
+    if ip_address is None:
+        ip_address = get_ip_addr_port()
+
+    if name is None:
+        name = get_username()
+
+    try:
+        return (
+            hisock.client.connect(ip_address, name=name)
+            if not threaded
+            else hisock.client.threaded_connect(ip_address, name=name)
+        )
+    except Exception as error:
+        log_error(error)
+        raise error
diff --git a/examples/tictactoe/tictactoe_client.py b/examples/tictactoe/tictactoe_client.py
index 3fc59ec..387587c 100644
--- a/examples/tictactoe/tictactoe_client.py
+++ b/examples/tictactoe/tictactoe_client.py
@@ -1,178 +1,171 @@
-import os
-import sys
-import pathlib
+"""HiSock TicTacToe client side (no GUI)"""
 
-PATH = pathlib.Path(__file__)
+### Setup ###
+from shared import connect_to_server, log_error
+from os import system, name as os_name
 
-# Import stuff, as this situation's a bit wonky
-# This is not needed in usual hisock code. However, in this odd case,
-# imports need to be configured first like this
-sys.path.append(
-    str(PATH.parent.parent.parent)
-)  # Not needed usually; used to run examples
+# HiSock setup
+print("TicTacToe Client - NO GUI")
+client = connect_to_server()
 
-from hisock import connect
+### Classes ###
+class TicTacToe:
+    board = [
+        [" ", " ", " "],
+        [" ", " ", " "],
+        [" ", " ", " "],
+    ]
 
+    @staticmethod
+    def display_board():
+        system("cls" if os_name == "nt" else "clear")
 
-# Data class, as to prevent using those dirty globals
-class Data:
-    def __init__(self):
-        self.tictactoe_opponent = None  # Tic Tac Toe opponent name
-        self.board = [" " for _ in range(9)]  # Tic Tac Toe board
-        self.board_layout = (
-            "{} |{} |{} \n--+--+--\n{} |{} |{} \n--+--+--\n{} |{} |{} \n"
-        )
-        self.letter = ""
+        row_sep = "---+---+---"
+        for row_idx, row in enumerate(TicTacToe.board):
+            for col_idx, col in enumerate(row):
+                print("{:^3}".format(col), end="")
+                if col_idx < 2:
+                    print("|", end="")
+            print(end="\n")
+            if row_idx < 2:
+                print(row_sep)
 
 
-def clear():
-    """Well, no pycharm support, so boo hoo"""
-    if os.name == "nt":
-        os.system("cls")
-    elif os.name == "posix":
-        os.system("clear")
+### Functions ###
+def ask_go_again() -> bool:
+    """Asks the user if they want to play again and returns it"""
+    while True:
+        answer = input("Do you want to play again? (Y/n): ")
 
+        response = None
+        response = True if answer.lower() in ["y", "yes", ""] else response
+        response = False if answer.lower() in ["n", "no"] else response
+        if response is not None:
+            break
 
+        print('Please enter "y" or "n"')
+
+    return response
+
+
+def get_move() -> tuple:
+    """Asks the user for a move and returns it"""
+
+    while True:
+        move: str = input("Enter a move (1-9): ")
+
+        if move == "":
+            print("The move cannot be blank")
+            continue
+        if not move.isnumeric():
+            print("The move must be a number")
+            continue
+
+        move = int(move)
+        if move < 1 or move > 9:
+            print("The move must be between 1 and 9")
+            continue
+
+        # Check if the move is available
+        move_indeces = ((move - 1) // 3, (move - 1) % 3)
+        if TicTacToe.board[move_indeces[0]][move_indeces[1]] != " ":
+            print("The move is already taken")
+            continue
+
+        break
+
+    return move_indeces
+
+
+### HiSock listeners ###
+
+# Joining
+@client.on("join_result")
+def join_result(game_info: dict):
+    if not game_info["worked"]:
+        print(f"Couldn't connect to server!\n{game_info['message']}")
+        raise StopIteration
+
+    print(game_info["message"])
+
+
+@client.on("client_joined")
+def client_joined(client_info: dict):
+    print(f'{client_info["name"]} joined the game!')
+
+
+# Disconnecting
+@client.on("force_disconnect")
+def force_disconnect():
+    raise SystemExit
+
+
+@client.on("game_ended")
+def game_ended(end_info: dict):
+    print(f"Game ended because {end_info['reason']}")
+
+    # Ask to go again
+    if not end_info["can_go_again"]:
+        raise KeyboardInterrupt  # Can't go again, leave
+
+    if ask_go_again():
+        client.send("go_again", {"go_again": True})
+        print("Waiting for other player to respond...")
+        return
+
+    # Tell the server we're done
+    client.send("go_again", {"go_again": False})
+    raise StopIteration
+
+
+@client.on("client_left")
+def client_left(client_info: dict):
+    print(f'{client_info["name"]} left the game!')
+    # If the client is us, then stop
+    if client_info["name"] == client.name:
+        raise StopIteration
+
+
+# Actual TicTacToe stuffs
+@client.on("update_board")
+def update_board(board_information: dict):
+    TicTacToe.board = board_information["board"]
+    TicTacToe.display_board()
+
+
+@client.on("make_move")
+def make_move(_: bytes):
+    move = get_move()
+    client.send("made_move", {"move": move})
+
+
+### Run ###
 def run():
-    # IP, Port, and Name establishment
-    ip_input = input("Enter IP of where server is hosted: ")
-
-    port_input = input("Enter port of server: ")
-    while port_input == "":
-        port_input = input("Enter port of server: ")
-    port_input = int(port_input)
-
-    name = input("Enter name (MAX LENGTH: 32): ")
-    while name == "" or len(name) > 32:
-        name = input("Enter name (MAX LENGTH: 32): ")
-
-    print(f"Connecting to server at {ip_input}:{port_input}...", end=" ")
-
-    client = connect((ip_input, port_input), name)
-
-    print("SUCCESS!")
-    clear()
-    print("Waiting for pairing... ")
-
-    data = Data()
-
-    # Hisock message receive decorators
-    @client.on("game_start")
-    def game_started(opponent: str):
-        print(f'Pairing succeeded! Opponent: "{opponent}"')
-        data.tictactoe_opponent = opponent
-        goes_first = client.recv_raw()
-
-        if goes_first == b"First":
-            # Receives message from server; goes first, therefore is X
-            data.letter = "X"
-            print("You are X")
-            print(data.board_layout.format(*data.board))
-            move_input = input("Enter your move (1-9, left to right, top to bottom): ")
-            while (
-                move_input == ""
-                or not move_input.isdigit()
-                or (move_input.isdigit() and not 1 <= int(move_input) <= 9)
-            ):
-                move_input = input(
-                    "Enter your move (1-9, left to right, top to bottom): "
-                )
-            move_input = int(move_input)
-            data.board[move_input - 1] = "X"
-
-            # Refreshes the screen
-            clear()
-            print("You are X")
-            print(data.board_layout.format(*data.board))
-
-            # Sends to server the index of the move
-            client.send("player_turn", str(move_input - 1).encode())
-        else:
-            # Goes last, therefore is O
-            data.letter = "O"
-            print("You are O")
-            print(data.board_layout.format(*data.board))
-
-    @client.on("player_turn")
-    def player_turn(move: int):
-        # GETS CALLED EVERY TURN AFTER FIRST DETERMINATION
-
-        data.board[move] = "X" if data.letter == "O" else "O"
-
-        # Refreshes the screen
-        clear()
-        print(f"You are {data.letter}")
-        print(data.board_layout.format(*data.board))
-
-        move_input = input("Enter your move (1-9, left to right, top to bottom): ")
-        while (
-            move_input == ""
-            or not move_input.isdigit()
-            or (move_input.isdigit() and not 1 <= int(move_input) <= 9)
-            or (move_input.isdigit() and data.board[int(move_input) - 1] != " ")
-        ):
-            move_input = input("Enter your move (1-9, left to right, top to bottom): ")
-        move_input = int(move_input)
-        data.board[move_input - 1] = data.letter
-
-        # Refreshes the screen after input
-        clear()
-        print(f"You are {data.letter}")
-        print(data.board_layout.format(*data.board))
-
-        client.send("player_turn", str(move_input - 1).encode())
-
-    @client.on("win")
-    def player_win(missing_move: int):
-        # Player won
-
-        if data.board[missing_move] == " ":
-            data.board[missing_move] = "X" if data.letter == "O" else "O"
-
-        # Refreshes the screen
-        clear()
-        print("You win!")
-        print(data.board_layout.format(*data.board))
-
-        sys.exit()
-
-    @client.on("lose")
-    def player_lose(missing_move: int):
-        # Player lost
-
-        if data.board[missing_move] == " ":
-            data.board[missing_move] = "X" if data.letter == "O" else "O"
-
-        # Refreshes the screen
-        clear()
-        print("You lose!")
-        print(data.board_layout.format(*data.board))
-
-        sys.exit()
-
-    @client.on("tie")
-    def player_tie(missing_move: int):
-        # Players tied
-
-        if data.board[missing_move] == " ":
-            data.board[missing_move] = "X" if data.letter == "O" else "O"
-
-        # Refreshes the screen
-        clear()
-        print("You tied!")
-        print(data.board_layout.format(*data.board))
-
-        sys.exit()
-
-    @client.on("opp_disc")
-    def opponent_leave(opp_name: str):
-        clear()
-        print(f'Opponent "{opp_name}"disconnected; disconnecting from server...')
-        raise SystemExit
-
-    while not client.closed:
-        # Updates server
-        client.update()
+    while True:
+        try:
+            client.update()
+        except (SystemExit, BrokenPipeError):
+            # This is raised when the server is closed
+            print("\nThe server has stopped, exiting!")
+            raise
+        except KeyboardInterrupt:
+            print("\nExiting gracefully", end="... ")
+            client.send("leave", b"simply left the game")
+            break
+        except StopIteration:
+            # This is raised when the player already left ("leave" sent)
+            break
+        except Exception as error:
+            log_error(error)
+            break
+
+    try:
+        # Close the client
+        client.close(emit_leave=False)
+    except KeyboardInterrupt:
+        print("FORCING")
+    finally:
+        raise SystemExit(0)
 
 
 if __name__ == "__main__":
diff --git a/examples/tictactoe/tictactoe_pygame_client.py b/examples/tictactoe/tictactoe_pygame_client.py
index d268586..52cc073 100644
--- a/examples/tictactoe/tictactoe_pygame_client.py
+++ b/examples/tictactoe/tictactoe_pygame_client.py
@@ -1,118 +1,431 @@
-import sys
-import pathlib
+"""HiSock TicTacToe client side (GUI)"""
+
+### Setup ###
+import hisock
 import pygame
+import pygame_gui
+from shared import connect_to_server, log_error
+from typing import Union
 
-from functools import lru_cache
+# Pygame setup
+SCREEN_SIZE = (400, 600)
+BOARD_SIZE = (400, 400)
+TOP_MARGIN = 200
+BACKGROUND_COLOR = (128, 128, 0)
+MARGIN = 20
+pygame.init()
+pygame.display.set_caption("TicTacToe")
+screen = pygame.display.set_mode(SCREEN_SIZE)
+clock = pygame.time.Clock()
 
-PATH = pathlib.Path(__file__)
+### Classes ###
+class DeltaTime:
+    """A dataclass for the Pygame deltatime, don't question"""
 
-# Import stuff, as this situation's a bit wonky
-# This is not needed in usual hisock code. However, in this odd case (two same named directories),
-# imports need to be configured first like this
-sys.path.append(
-    str(PATH.parent.parent.parent)
-)  # Not needed usually; used to run examples
+    current = 0
 
-from hisock.client import threaded_connect
+    @staticmethod
+    def update():
+        DeltaTime.current = clock.tick(30)
 
 
-# Data class, as to prevent using those dirty globals
-class Data:
-    def __init__(self):
-        self.tictactoe_opponent = None  # Tic Tac Toe opponent name
-        self.board = [" " for _ in range(9)]  # Tic Tac Toe board
-        self.letter = ""
-        self.turn = None
-        self.game_start = False
+class TicTacToe:
+    board = [
+        [" ", " ", " "],
+        [" ", " ", " "],
+        [" ", " ", " "],
+    ]
 
+    our_turn = False
+    playing = False
+    game_over = False
+    game_over_message = ""
 
-@lru_cache
-def load_font(size):
-    return pygame.font.Font(None, size)
 
+class Cache:
+    text_cache = {}  # key will be a tuple of the position and the text
+    buttons = {}  # key will be a tuple of the position
 
-def run():
-    pygame.init()
 
-    ip_input = input("Enter IP of where server is hosted: ")
+### Functions ###
+def ask_go_again() -> bool:
+    """Asks the user if they want to play again and returns it"""
+    while True:
+        answer = input("Do you want to play again? (Y/n): ")
+
+        response = None
+        response = True if answer.lower() in ["y", "yes", ""] else response
+        response = False if answer.lower() in ["n", "no"] else response
+        if response is not None:
+            break
 
-    port_input = input("Enter port of server: ")
-    while port_input == "":
-        port_input = input("Enter port of server: ")
-    port_input = int(port_input)
+        print('Please enter "y" or "n"')
 
-    name = input("Enter name (MAX LENGTH: 32): ")
-    while name == "" or len(name) > 32:
-        name = input("Enter name (MAX LENGTH: 32): ")
+    return response
 
-    print(f"Connecting to server at {ip_input}:{port_input}...", end=" ")
 
-    server = threaded_connect((ip_input, port_input), name)
-    print("SUCCESS!")
+def move_made(position: tuple):
+    """
+    Send a move to the server
+    :param position: The position of the button that was pressed
+    """
 
-    data = Data()
+    TicTacToe.our_turn = False
 
-    @server.on("game_start")
-    def game_start(opponent: str):
-        data.game_start = True
-        data.tictactoe_opponent = opponent
-        goes_first = server.recv_raw()
+    # Convert button position to board position
+    row_idx = (position[1] - TOP_MARGIN) // (BOARD_SIZE[1] // 3)
+    col_idx = (position[0] - MARGIN) // (BOARD_SIZE[0] // 3)
 
-        if goes_first == b"First":
-            data.turn = True
-            data.letter = "X"
-        else:
-            data.turn = False
-            data.letter = "O"
+    return row_idx, col_idx
 
-    screen = pygame.display.set_mode((400, 400))
 
-    server.start_client()
+### Pygame functions ###
+def text_display(message: str, position: tuple, size: int = 48):
+    """Display a text on the screen"""
 
-    opp_font = load_font(20)
+    def get_text_rect_surf() -> tuple[pygame.Rect, pygame.Surface]:
+        # Cache
+        if (position, message) in Cache.text_cache:
+            return Cache.text_cache[(position, message)]
 
-    while True:
-        for event in pygame.event.get():
-            if event.type == pygame.QUIT:
-                server.stop_client()
-                pygame.quit()
-                sys.exit()
-            elif event.type == pygame.MOUSEBUTTONDOWN and data.game_start and data.turn:
-                bod_idx = [xy // 133 for xy in event.pos]
-
-                board_idx = bod_idx[0] * 3 + bod_idx[1]
-                data.board[board_idx] = data.letter
-                print(bod_idx)
-
-            screen.fill((51, 168, 12))
-            pygame.draw.line(screen, (0, 0, 0), (133, 20), (133, 380), width=10)
-            pygame.draw.line(screen, (0, 0, 0), (266, 20), (266, 380), width=10)
-            pygame.draw.line(screen, (0, 0, 0), (20, 133), (380, 133), width=10)
-            pygame.draw.line(screen, (0, 0, 0), (20, 266), (380, 266), width=10)
-
-            if data.tictactoe_opponent is None:
-                opp_txt = opp_font.render("Waiting for opponent...", True, (0, 0, 0))
-            else:
-                opp_txt = opp_font.render(
-                    f"Opponent: {data.tictactoe_opponent}", True, (0, 0, 0)
-                )
+        text_surface = pygame.font.SysFont("Arial", size).render(
+            message, False, "black"
+        )
+        text_rect = text_surface.get_rect(center=position)
+
+        # Add to cache
+        Cache.text_cache[(position, message)] = (text_rect, text_surface)
+
+        return text_rect, text_surface
+
+    text_rect, text_surface = get_text_rect_surf()
+    screen.blit(text_surface, text_rect)
+
+
+def get_button_rect(position: tuple, size: tuple) -> pygame.Rect:
+    # Cache
+    if position in Cache.buttons:
+        return Cache.buttons[position]
+
+    button_rect = pygame.Surface(size).get_rect(center=position)
+
+    # Add to cache
+    Cache.buttons[position] = button_rect
+
+    return button_rect
 
-            name_txt = opp_font.render(f"You are: {name}", True, (0, 0, 0))
 
-            if data.turn:
-                turn_txt = opp_font.render("Your turn", True, (0, 0, 0))
-            elif data.turn is not None:
-                turn_txt = opp_font.render("Opponent's Turn", True, (0, 0, 0))
-            else:
-                turn_txt = None
+def button_display(
+    position: tuple, size: tuple, color: Union[tuple, str], border_width: int = 0
+):
+    """
+    Draw a button on the screen
+    Returns if the button has been clicked or not
+    """
 
-            screen.blit(opp_txt, (0, 0))
-            screen.blit(name_txt, (0, 20))
+    button_rect = get_button_rect(position, size)
+    pygame.draw.rect(screen, color, button_rect, border_width)
 
-            if turn_txt is not None:
-                screen.blit(turn_txt, opp_txt.get_rect(topright=(380, 0)))
+    return (
+        button_rect.collidepoint(pygame.mouse.get_pos())
+        and pygame.mouse.get_pressed()[0]
+    )
 
-            pygame.display.update()
+
+### Pygame states ###
+class BaseState:
+    """Base class for all Pygame states"""
+
+    def event_handling(self, event: pygame.event.Event):
+        # No event handling specified
+        pass
+
+    def draw(self):
+        print(f"Warning: draw() not implemented in {self.__class__.__name__}")
+
+
+class ConnectToServerState(BaseState):
+    def __init__(self):
+        self.gui_manager = pygame_gui.UIManager((SCREEN_SIZE[0], SCREEN_SIZE[1]))
+
+        # Create username input
+        self.username_input_rect = pygame.Rect(0, 0, SCREEN_SIZE[0] * 0.9, 50)
+        self.username_input_rect.center = (SCREEN_SIZE[0] // 2, 100)
+        self.username_input = pygame_gui.elements.ui_text_entry_line.UITextEntryLine(
+            relative_rect=self.username_input_rect, manager=self.gui_manager
+        )
+
+        # Create server input
+        self.server_input_rect = pygame.Rect(0, 0, SCREEN_SIZE[0] * 0.9, 50)
+        self.server_input_rect.center = (SCREEN_SIZE[0] // 2, 200)
+        self.server_input = pygame_gui.elements.ui_text_entry_line.UITextEntryLine(
+            relative_rect=self.server_input_rect, manager=self.gui_manager
+        )
+
+        # Set allowed characters to be alphanum with an underscore
+        alpha_num_underscore = [
+            char
+            for char in str(
+                "abcdefghijklmnopqrstuvwxyz"
+                + "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+                + "0123456789"
+                + "_"
+            )
+        ]
+        self.username_input.set_allowed_characters(alpha_num_underscore)
+        self.server_input.set_allowed_characters(alpha_num_underscore + [".", ":"])
+
+    def focus_current_textbox(self):
+        """A hacky solution to a dumb problem"""
+
+        for text_element in (self.username_input, self.server_input):
+            if text_element.relative_rect.collidepoint(pygame.mouse.get_pos()):
+                text_element.focus()
+                return
+            text_element.unfocus()
+
+    def draw(self):
+        text_display("Username:", (SCREEN_SIZE[0] // 2, 48))
+        text_display("Server IP:", (SCREEN_SIZE[0] // 2, 48 + 100))
+        if button_display((SCREEN_SIZE[0] // 2, 48 + 200), (200, 50), "green"):
+            # Connect to server
+            print("Connecting to server...")
+            self.connect_to_server(self.username_input.text, self.server_input.text)
+        text_display("Connect", (SCREEN_SIZE[0] // 2, 48 + 200))
+        self.gui_manager.update(DeltaTime.current)
+        self.gui_manager.draw_ui(screen)
+
+    def event_handling(self, event: pygame.event.Event):
+        self.focus_current_textbox()
+        self.username_input.process_event(event)
+        self.server_input.process_event(event)
+        self.username_input.enable()
+        self.server_input.enable()
+
+    def connect_to_server(self, username_input: str, server_input: str):
+        try:
+            # Convert the server input to a tuple
+            server_input = server_input.split(":")
+            server_input = (server_input[0], int(server_input[1]))
+            client = connect_to_server(server_input, username_input, True)
+        except Exception as error:
+            log_error(error)
+            return
+
+        State.current = GameState(client)
+
+
+class GameState(BaseState):
+    def __init__(self, client):
+        self.client: hisock.client.ThreadedHiSockClient = client
+
+        # Joining
+        @client.on("join_result")
+        def join_result(game_info: dict):
+            if not game_info["worked"]:
+                print(f"Couldn't connect to server!\n{game_info['message']}")
+                raise StopIteration
+
+            print(game_info["message"])
+
+        @client.on("client_joined")
+        def client_joined(client_info: dict):
+            print(f'{client_info["name"]} joined the game!')
+
+        # Disconnecting
+        @client.on("force_disconnect")
+        def force_disconnect():
+            raise SystemExit
+
+        @client.on("game_ended")
+        def game_ended(end_info: dict):
+            if not end_info["can_go_again"]:
+                raise KeyboardInterrupt  # Can't go again, leave
+
+            TicTacToe.game_over = True
+            TicTacToe.game_over_message = end_info["reason"]
+            TicTacToe.playing = False
+
+        @client.on("client_left")
+        def client_left(client_info: dict):
+            print(f'{client_info["name"]} left the game!')
+            # If the client is us, then stop
+            if client_info["name"] == client.name:
+                raise StopIteration
+
+        # Actual TicTacToe stuffs
+        @client.on("update_board")
+        def update_board(board_information: dict):
+            # update_board is also called when the server starts the game
+            TicTacToe.playing = True
+            TicTacToe.board = board_information["board"]
+
+        @client.on("make_move")
+        def make_move(_: bytes):
+            TicTacToe.our_turn = True
+
+        # The client will be threaded, no need for `update` every iteration
+        client.start_client()
+
+    def draw_status_text(self):
+        text_display(
+            f"Your name: {self.client.name}", (SCREEN_SIZE[0] // 2, 24), size=24
+        )
+        if TicTacToe.our_turn:
+            text_display(
+                "Your turn, make a move!", (SCREEN_SIZE[0] // 2, 24 + 48), size=24
+            )
+        else:
+            text_display(
+                "Waiting for opponent move...", (SCREEN_SIZE[0] // 2, 24 + 48), size=24
+            )
+
+    def available_space_button(self, position: tuple):
+        button_rect = get_button_rect(
+            position, (BOARD_SIZE[0] // 3, BOARD_SIZE[1] // 3)
+        )
+        # Collision checking
+        mouse_pos = pygame.mouse.get_pos()
+        if pygame.mouse.get_pressed()[0] and button_rect.collidepoint(mouse_pos):
+            row_idx, col_idx = move_made(position)
+            self.client.send("made_move", {"move": [row_idx, col_idx]})
+
+    def draw_board(self):
+        for row_idx, row in enumerate(TicTacToe.board):
+            for col_idx, col in enumerate(row):
+                if col_idx < 2:
+                    pygame.draw.line(
+                        screen,
+                        "black",
+                        (
+                            (BOARD_SIZE[0] // 3) * (col_idx + 1),
+                            MARGIN + TOP_MARGIN,
+                        ),
+                        (
+                            (BOARD_SIZE[0] // 3) * (col_idx + 1),
+                            BOARD_SIZE[1] - MARGIN + TOP_MARGIN,
+                        ),
+                        width=10,
+                    )
+
+                position_center = (
+                    (BOARD_SIZE[0] // 3) * (col_idx + 1) - BOARD_SIZE[0] // 6,
+                    (BOARD_SIZE[1] // 3) * (row_idx + 1)
+                    - BOARD_SIZE[1] // 6
+                    + TOP_MARGIN,
+                )
+
+                # Draw an X or O
+                if col != " ":
+                    text_display(col, position_center, size=72)
+                    continue
+
+                # The space is blank, have a button the user can press if it's our turn
+                if not TicTacToe.our_turn:
+                    continue
+
+                self.available_space_button(position_center)
+
+            if row_idx < 2:
+                pygame.draw.line(
+                    screen,
+                    "black",
+                    (
+                        MARGIN,
+                        (BOARD_SIZE[1] // 3) * (row_idx + 1) + TOP_MARGIN,
+                    ),
+                    (
+                        BOARD_SIZE[0] - MARGIN,
+                        (BOARD_SIZE[1] // 3) * (row_idx + 1) + TOP_MARGIN,
+                    ),
+                    width=10,
+                )
+
+    @staticmethod
+    def draw_waiting_room():
+        text_display("Waiting for opponent...", (SCREEN_SIZE[0] // 2, 24), size=24)
+
+    def draw_game_over(self):
+        text_display("Game over!", (SCREEN_SIZE[0] // 2, 24), size=24)
+        text_display(
+            TicTacToe.game_over_message, (SCREEN_SIZE[0] // 2, 24 * 2), size=24
+        )
+        text_display("Go again?", (SCREEN_SIZE[0] // 2, 24 * 4), size=24)
+        if button_display((SCREEN_SIZE[0] // 2, 24 * 6), size=(200, 50), color="red"):
+            self.client.send("go_again", {"go_again": False})
+            raise StopIteration
+        text_display("No", (SCREEN_SIZE[0] // 2, 24 * 6), size=24)
+        if button_display((SCREEN_SIZE[0] // 2, 24 * 8), size=(200, 50), color="green"):
+            self.client.send("go_again", {"go_again": True})
+            TicTacToe.game_over = False
+            TicTacToe.playing = False
+        text_display("Yes", (SCREEN_SIZE[0] // 2, 24 * 8), size=24)
+
+    def draw(self):
+        # Game over
+        if TicTacToe.game_over:
+            self.draw_game_over()
+            return
+
+        # We're not playing, so draw the waiting room
+        if not TicTacToe.playing:
+            self.draw_waiting_room()
+            return
+
+        self.draw_status_text()
+        self.draw_board()
+
+
+class State:
+    """A dataclass for handling the state (no pesky globals)"""
+
+    current = ConnectToServerState()
+
+
+### Run ###
+def run_pygame_loop():
+    # Event handling
+    for event in pygame.event.get():
+        State.current.event_handling(event)
+        if event.type == pygame.QUIT:
+            raise KeyboardInterrupt
+
+    screen.fill(BACKGROUND_COLOR)
+    State.current.draw()
+    pygame.display.flip()
+    DeltaTime.update()
+
+
+def run():
+    while True:
+        try:
+            run_pygame_loop()
+        except (SystemExit, BrokenPipeError):
+            # This is raised when the server is closed
+            print("\nThe server has stopped, exiting!")
+            raise
+        except KeyboardInterrupt:
+            print("\nExiting gracefully", end="... ")
+            if isinstance(State.current, GameState):
+                State.current.client.send("leave", b"simply left the game")
+                State.current.client.close()
+            break
+        except StopIteration:
+            # This is raised when the player already left ("leave" sent)
+            break
+        except Exception as error:
+            log_error(error)
+            break
+
+    pygame.quit()
+    try:
+        # Close the client
+        # client.stop_client()
+        pass
+    except KeyboardInterrupt:
+        print("FORCING")
+    finally:
+        raise SystemExit(0)
 
 
 if __name__ == "__main__":
diff --git a/examples/tictactoe/tictactoe_server.py b/examples/tictactoe/tictactoe_server.py
index d6a6ac9..a084595 100644
--- a/examples/tictactoe/tictactoe_server.py
+++ b/examples/tictactoe/tictactoe_server.py
@@ -1,176 +1,342 @@
-# Imports
+"""HiSock TicTacToe server side"""
+
+### Setup ###
+import hisock
+from shared import get_ip_addr_port, log_error
+
+# Setup server
+print("TicTacToe Server")
+ip_addr, port = get_ip_addr_port()
+print(f"Starting server at {ip_addr}:{port}", end="... ")
+try:
+    server = hisock.start_server((ip_addr, port))
+except Exception as error:
+    print(f"fail!\n{error!s}")
+    exit(1)
+print("done!")
+
+### Classes ###
+class Data:
+    paired_clients = []
+    turn = "x"
+
+    @staticmethod
+    def add_player(client_info: dict, player_turn: str):
+        Data.paired_clients.append(
+            {
+                "name": client_info["name"],
+                "ip": client_info["ip"],
+                "turn": player_turn,
+                "playing": False,
+            }
+        )
 
-import pathlib
-import random
-import statistics
-import sys
+    @staticmethod
+    def get_client_info(name: str = None, turn: str = None) -> tuple[dict, int]:
+        """
+        Takes in a name (or turn) and returns the dictionary which the
+        client is in and the index of the client in the paired_clients list.
+        If the client is not found, returns (None, -1).
+        """
+
+        compare_to: str = name
+        look_for: str = "name"
+        if turn is not None:
+            compare_to = turn
+            look_for = "turn"
+
+        return next(
+            (
+                (client, client_idx)
+                for client_idx, client in enumerate(Data.paired_clients)
+                if client[look_for] == compare_to
+            ),
+            (None, -1),
+        )
 
-# Import stuff, as this situation's a bit wonky
-# This is not needed in usual hisock code. However, in this odd case,
-# imports need to be configured first like this
+    @staticmethod
+    def switch_turn():
+        Data.turn = "x" if Data.turn != "x" else "o"
 
-sys.path.append(
-    str(pathlib.Path(__file__).parent.parent.parent)
-)  # Not needed usually; used to run examples
 
-from hisock import start_server, utils
+class TicTacToe:
+    """Game code for TicTacToe"""
 
+    board = [
+        [" ", " ", " "],
+        [" ", " ", " "],
+        [" ", " ", " "],
+    ]
+    turn = "x"
 
-# Data class, as to prevent using those dirty globals
-class Data:
-    def __init__(self):
-        self.boards = []
+    @staticmethod
+    def reset():
+        TicTacToe.board = [
+            [" ", " ", " "],
+            [" ", " ", " "],
+            [" ", " ", " "],
+        ]
+        TicTacToe.turn = "x"
+
+    @staticmethod
+    def check_win() -> str:
+        """
+        Returns the player who won (X or O)
+        If no player one, returns an empty string
+        """
+
+        # Check all 8 possible win conditions
+
+        # Horizontal (3/8)
+        for row in range(3):
+            if (
+                (first_condition := TicTacToe.board[row][0])
+                == TicTacToe.board[row][1]
+                == TicTacToe.board[row][2]
+                != " "
+            ):
+                return first_condition
+
+        # Vertical (3/8)
+        for col in range(3):
+            if (
+                (first_condition := TicTacToe.board[0][col])
+                == TicTacToe.board[1][col]
+                == TicTacToe.board[2][col]
+                != " "
+            ):
+                return first_condition
+
+        # Diagonal (2/8)
+        if (
+            (first_condition := TicTacToe.board[0][0])
+            == TicTacToe.board[1][1]
+            == TicTacToe.board[2][2]
+            != " "
+        ):
+            return first_condition
+        if (
+            (first_condition := TicTacToe.board[0][2])
+            == TicTacToe.board[1][1]
+            == TicTacToe.board[2][0]
+            != " "
+        ):
+            return first_condition
+
+        return ""
+
+    @staticmethod
+    def check_lose() -> bool:
+        """Returns if there is a tie / loss"""
+
+        for row in TicTacToe.board:
+            for col in row:
+                if col == " ":
+                    return False
+
+        return True
+
+
+### Functions ###
+
+
+def emit_update_information():
+    server.send_all_clients("update_board", {"board": TicTacToe.board})
+
+
+def make_move():
+    player = Data.get_client_info(turn=Data.turn)[0]
+    print(f"Telling {player} to make a move")
+    server.send_client(player["ip"], "make_move", b"")
+
+
+def start_game():
+    Data.turn = "x"
+    emit_update_information()  # The board will be blank
+    make_move()
+
+
+def game_over(won: bool, winner_name: str = "", winner_turn: str = ""):
+    server.send_all_clients(
+        "game_ended",
+        {
+            "reason": (f"{winner_name} ({winner_turn}) won!" if won else "Tied!"),
+            "can_go_again": True,
+        },
+    )
+    TicTacToe.reset()
+
+    # Set all playing to false
+    for client in Data.paired_clients:
+        client["playing"] = False
+
+
+### HiSock listeners ###
+@server.on("join")
+def client_joined(client_info: dict):
+    # Game full condition
+    if len(Data.paired_clients) > 2:
+        server.send_client(
+            client_info["ip"],
+            "join_result",
+            {"worked": False, "message": "This server is full"},
+        )
 
+    # Pair client
+    player_turn = "x" if len(Data.paired_clients) % 2 == 1 else "o"
+    Data.paired_clients.append(
+        {
+            "name": client_info["name"],
+            "ip": client_info["ip"],
+            "turn": player_turn,
+            "playing": True,
+        }
+    )
+
+    # Emit join information
+    server.send_client(
+        client_info["ip"],
+        "join_result",
+        {
+            "worked": True,
+            "message": "Joined server",
+            "player_info": {"name": client_info["name"]},
+            "other_player": Data.paired_clients[0]
+            if len(Data.paired_clients) > 1
+            else None,
+        },
+    )
+    server.send_all_clients(
+        "client_joined",
+        {
+            "name": client_info["name"],
+            "turn": player_turn,
+            "ip": client_info["ip"],
+            "number_clients": len(Data.paired_clients),
+        },
+    )
+
+    # Server print
+    print(
+        f'"{client_info["name"]}"'
+        f'({hisock.utils.iptup_to_str(client_info["ip"])}) joined the server'
+    )
+
+    # Start game if two players are paired
+    if len(Data.paired_clients) != 2:
+        return
+
+    start_game()
+
+
+@server.on("leave")
+def client_left(client_info: dict):
+    # Check if client has already left
+    data_client_info = Data.get_client_info(name=client_info["name"])
+    if data_client_info[1] == -1:
+        return
+
+    # reason: str = reason.decode("utf-8")
+
+    Data.paired_clients.pop(data_client_info[1])
+
+    # Emit leave information
+    server.send_all_clients(
+        "client_left",
+        {
+            "name": client_info["name"],
+            "ip": client_info["ip"],
+            "number_clients": len(Data.paired_clients),
+            # "reason": reason,
+        },
+    )
+
+    # Server print
+    print(
+        f'"{client_info["name"]}" ({hisock.utils.iptup_to_str(client_info["ip"])}) '
+        # f"left the server because {reason}"
+    )
+
+    server.disconnect_client(client_info["ip"])
+
+    # Other player left condition
+    if len(Data.paired_clients) == 2:
+        return
+
+    server.send_all_clients(
+        "game_ended",
+        {
+            "reason": "the other player left",
+            "can_go_again": False,
+        },
+    )
+    TicTacToe.reset()
+
+
+@server.on("made_move")
+def client_made_move(client_info: dict, move: dict):
+    move: tuple = move["move"]  # Hisock doesn't have built-in tuple support
+
+    # Update game
+    turn = Data.get_client_info(client_info["name"])[0]["turn"]
+    TicTacToe.board[move[0]][move[1]] = turn
+    Data.switch_turn()
+    emit_update_information()
+
+    # Win condition
+    if TicTacToe.check_win():
+        game_over(True, client_info["name"], turn)
+
+    # Lose condition
+    elif TicTacToe.check_lose():
+        game_over(won=False)
 
-# IP and Port establishment
+    else:
+        make_move()
+        return
 
+    print("Game over!")
 
-def run():
-    if len(sys.argv) - 1 != 1:
-        ip_input = input(
-            "Enter IP of where the server should be hosted "
-            "(Leave blank for current IP): "
-        )
-        if ip_input == "":
-            ip_input = utils.get_local_ip()
 
-        port_input = input("Enter port of server: ")
-        while port_input == "":
-            port_input = input("Enter port of server: ")
+# TODO: Use `send_raw` and `recv_raw` instead of a whole new event
+# The reason I'm not doing that is I can't think of an easy way to deal
+# with having to receive a message from two clients (as `recv_raw` is
+# blocking)
+@server.on("go_again", threaded=True)
+def go_again(client_info: dict, response: dict):
+    if not response["go_again"] or len(Data.paired_clients) != 2:
+        server.disconnect_all_clients()
+        client_left(client_info, reason=b"didn't wanna go again")
 
-        port_input = int(port_input)
-    else:
-        ip_input = utils.get_local_ip()
-        port_input = 6969
+    print(f"{client_info['name']} wants to play again")
 
-    print(f"Starting server at {ip_input}:{port_input}...", end=" ")
-    server = start_server((ip_input, port_input))
-    print("SUCCESS!")
+    client_idx = Data.get_client_info(name=client_info["name"])[1]
+    Data.paired_clients[client_idx]["playing"] = True
 
-    # Tic Tac Toe variable initialization
-    paired_clients = []
-    paired_clients_ip = []
-    x_or_o = []
-
-    data = Data()
-
-    # Hisock message receive decorators
-    @server.on("join")
-    def client_conn(client_info):
-        print(f"Client \"{client_info['name']}\" connected")
-
-        # Gathers last pair/user from the list
-        try:
-            last_pair = paired_clients[-1]
-            last_ip_pair = paired_clients_ip[-1]
-        except IndexError:
-            last_pair = ("Place", "Holder")  # To conform to the 2-1 last pair searchup
-            last_ip_pair = ("Place", "Holder")
-
-        if len(last_ip_pair) == 1:  # Pair client to currently available one
-            print(f'    - Paired with "{last_pair[0]}"')
-            paired_clients[-1].append(client_info["name"])
-            paired_clients_ip[-1].append(client_info["ip"])
-
-            goes_first = random.random()
-
-            server.send_client(client_info["ip"], "game_start", last_pair[0].encode())
-            server.send_client(last_ip_pair[0], "game_start", client_info["name"].encode())
-
-            if goes_first < 0.5:
-                # Connected client goes first
-                server.send_client_raw(client_info["ip"], b"First")
-                server.send_client_raw(last_ip_pair[0], b"Last")
-                x_or_o.append(["O", "X"])
-            else:
-                # Unpaired client goes first
-                server.send_client_raw(last_ip_pair[0], b"First")
-                server.send_client_raw(client_info["ip"], b"Last")
-                x_or_o.append(["X", "O"])
-
-        elif len(last_pair) == 2:  # Available client
-            # Appended to variables - no other action taken
-            print("    - Currently available (no clients to pair)")
-            paired_clients.append([client_info["name"]])
-            paired_clients_ip.append([client_info["ip"]])
-
-    @server.on("leave")
-    def player_leave(clt_info):
-        print(paired_clients_ip)
-        print(f"Client \"{clt_info['name']}\" disconnected")
-        for ip_pair in paired_clients_ip[:]:
-            for ip in ip_pair:
-                if ip == clt_info["ip"]:
-                    if len(ip_pair) == 1:
-                        paired_clients_ip.remove(ip_pair)
-                    else:
-                        # Uhh I don't know what to do RN
-                        cp_ip_pair = ip_pair[:]
-                        cp_ip_pair.remove(ip)
-                        other_ip = cp_ip_pair[0]
-                        server.send_client(other_ip, "opp_disc", clt_info["name"].encode())
-                        paired_clients_ip.remove(ip_pair)
-        print(paired_clients_ip)
-
-    @server.on("player_turn")
-    def player_turn(clt_data, move: int):
-        opponent_ip = None  # Suppressing Pycharm warnings
-        opponent_letter = None
-        idx = None
-
-        # Locates index of player pair
-        for x, ip_pair in enumerate(paired_clients_ip):
-            for y, ip in enumerate(ip_pair):
-                if ip == clt_data["ip"]:
-                    opponent_ip = paired_clients_ip[x][abs(y - 1)]
-                    opponent_letter = x_or_o[x][abs(y - 1)]
-                    idx = [x, abs(y - 1)]
-
-        if len(data.boards) == idx[0]:
-            data.boards.append([" " for _ in range(9)])
-        data.boards[idx[0]][move] = x_or_o[idx[0]][idx[1]]
-
-        # Winning combos
-        row1, row2, row3 = [data.boards[idx[0]][i : i + 3] for i in range(0, 9, 3)]
-        col1, col2, col3 = [data.boards[idx[0]][i : 7 + i : 3] for i in range(3)]
-        diag1, diag2 = [
-            [data.boards[idx[0]][0], data.boards[idx[0]][4], data.boards[idx[0]][8]],
-            [data.boards[idx[0]][2], data.boards[idx[0]][4], data.boards[idx[0]][6]],
-        ]
-        no_winner = True
-
-        for check in (row1, row2, row3, col1, col2, col3, diag1, diag2):
-            if (check[0] == check[1] == check[2]) and statistics.mode(
-                check
-            ) != " ":  # WINNER!
-                no_winner = False
-                winner = statistics.mode(check)  # Checks the most occurances
-                winner = "X" if winner == "O" else "O"
-
-                if winner == opponent_letter:
-                    server.send_client(opponent_ip, "win", str(move).encode())
-                    server.send_client(clt_data["ip"], "lose", str(move).encode())
-                else:
-                    server.send_client(clt_data["ip"], "win", str(move).encode())
-                    server.send_client(opponent_ip, "lose", str(move).encode())
-
-        spaces = [i == " " for i in data.boards[idx[0]]]
-
-        if not any(spaces):
-            # No spaces and no winner - Tied
-            no_winner = False  # Even though it's technically true
-            server.send_client(opponent_ip, "tie", str(move).encode())
-            server.send_client(clt_data["ip"], "tie", str(move).encode())
-
-        if no_winner:
-            server.send_client(opponent_ip, "player_turn", str(move).encode())
-
-    while not server.closed:
-        try:
-            # Runs server
-            server.run()
-        except utils.NoMessageException:
-            pass
+    # Both clients are playing check
+    for client in Data.paired_clients:
+        if not client["playing"]:
+            return
+
+    start_game()
+
+
+### Run ###
+def run():
+    try:
+        server.start()
+    except Exception as e:
+        log_error(e)
+
+    try:
+        print("\nShutting down gracefully", end="... ")
+        server.disconnect_all_clients()
+        server.close()
+        print("done!")
+    except KeyboardInterrupt:
+        print("FORCING")
+    finally:
+        exit(0)
 
 
 if __name__ == "__main__":
diff --git a/hisock/__init__.py b/hisock/__init__.py
index 67621c8..343bac3 100644
--- a/hisock/__init__.py
+++ b/hisock/__init__.py
@@ -1,8 +1,18 @@
-import hisock.constants as constants  # lgtm [py/unused-import]
-import hisock.utils as utils  # lgtm [py/unused-import] lgtm [py/import-and-import-from]
+"""
+This module makes the package importable to users.
 
-import hisock.client as client  # lgtm [py/unused-import]
-import hisock.server as server  # lgtm [py/unused-import]
+====================================
+Copyright SSS_Says_Snek, 2022-present
+====================================
+"""
+
+from hisock import constants  # lgtm [py/unused-import]
+from hisock import utils  # lgtm [py/unused-import] lgtm [py/import-and-import-from]
+
+from hisock import client  # lgtm [py/unused-import]
+from hisock import server  # lgtm [py/unused-import]
+
+from hisock.constants import __version__  # lgtm [py/unused-import]
 
 from hisock.constants import __version__
 
@@ -10,8 +20,14 @@
     start_server,
     start_threaded_server,
     HiSockServer,
+    ThreadedHiSockServer,
+)  # lgtm [py/unused-import]
+from .client import (
+    connect,
+    threaded_connect,
+    HiSockClient,
+    ThreadedHiSockClient,
 )  # lgtm [py/unused-import]
-from .client import connect, threaded_connect, HiSockClient  # lgtm [py/unused-import]
 from .utils import (  # lgtm [py/unused-import]
     get_local_ip,  # lgtm [py/unused-import]
     input_client_config,
@@ -20,4 +36,4 @@
     iptup_to_str,  # lgtm [py/unused-import]
 )
 
-import examples  # lgtm [py/unused-import]
\ No newline at end of file
+import examples  # lgtm [py/unused-import]
diff --git a/hisock/_shared.py b/hisock/_shared.py
new file mode 100644
index 0000000..6520371
--- /dev/null
+++ b/hisock/_shared.py
@@ -0,0 +1,425 @@
+"""
+This module contains shared code for both HiSockServer and HiSockClient.
+
+====================================
+Copyright SSS_Says_Snek, 2022-present
+====================================
+"""
+
+import inspect
+import threading
+from typing import Callable, Union, Any
+
+try:
+    from .utils import (
+        FunctionNotFoundException,
+        ClientInfo,
+        MessageCacheMember,
+        Sendable,
+        validate_command_not_reserved,
+        _type_cast,
+        _str_type_to_type_annotations_dict,
+    )
+except ImportError:
+    from utils import (
+        FunctionNotFoundException,
+        ClientInfo,
+        MessageCacheMember,
+        Sendable,
+        validate_command_not_reserved,
+        _type_cast,
+        _str_type_to_type_annotations_dict,
+    )
+
+
+class _HiSockBase:
+    """
+    Base class for both :class:`HiSockClient` and :class:`HiSockServer`.
+    See their documentation for more info.
+    """
+
+    def __init__(
+        self, addr: tuple[str, int], header_len: int = 16, cache_size: int = -1
+    ):
+        self.addr = addr
+        self.header_len = header_len
+
+        # Function related storage
+        # {"command": {"func": Callable, "name": str, "type_hint": {"arg": Any}, "threaded": bool, "override": bool}}
+        self.funcs = {}
+        # {event_name: {"thread_event": threading.Event, "data": Union[None, bytes]}}
+        # If catching all, then event_name will be a number sandwiched by dollar signs
+        # Then `update` will handle the event with the lowest number
+        self._recv_on_events: dict[str, Any] = {}
+
+        # Cache
+        self.cache_size = cache_size
+        # cache_size <= 0: No cache
+        if cache_size > 0:
+            self.cache = []
+
+        # Flags
+        self.closed = False
+        self._receiving_data = False
+
+    # Internal methods
+
+    def _type_cast_client_data(
+        self, command: str, client_data: dict
+    ) -> Union[ClientInfo, dict]:
+        """
+        Type cast client info accordingly.
+        If the type hint is None, then the client info is returned as is (a dict).
+
+        :param command: The name of the function that called this method.
+        :type command: str
+        :param client_data: The client data to type cast.
+        :type client_data: dict
+
+        :return: The type casted client data from the type hint.
+        :rtype: Union[ClientInfo, dict]
+        """
+
+        type_cast_to = self.funcs[command]["type_hint"]["client_data"]
+        if type_cast_to is None:
+            type_cast_to = ClientInfo
+
+        if type_cast_to is ClientInfo:
+            return ClientInfo(**client_data)
+        return client_data
+
+    @staticmethod
+    def _send_type_cast(content: Sendable) -> bytes:
+        """
+        Type casting content for the send methods.
+        This method exists so type casting can easily be changed without changing
+        all the send methods.
+
+        :param content: The content to type cast
+        :type content: Sendable
+
+        :return: The type casted content
+        :rtype: bytes
+
+        :raises InvalidTypeCast: If the content cannot be type casted
+        """
+
+        return _type_cast(
+            type_cast=bytes,
+            content_to_type_cast=content,
+            func_name="<sending function>",
+        )
+
+    def _cache(
+        self,
+        has_listener: bool,
+        command: str,
+        content: bytes,
+        full_data: bytes,
+        content_header: bytes,
+    ):
+        if self.cache_size <= 0:
+            return
+
+        cache_content = content if has_listener else full_data
+        self.cache.append(
+            MessageCacheMember(
+                {
+                    "header": content_header,
+                    "content": cache_content,
+                    "called": has_listener,
+                    "command": command,
+                }
+            )
+        )
+
+        # Pop oldest from stack
+        if 0 < self.cache_size < len(self.cache):
+            self.cache.pop(0)
+
+    # On decorator
+
+    def _call_wildcard_function(
+        self,
+        command: Union[str, None],
+        content: bytes,
+        client_data: Union[dict, None] = None,
+    ):
+        """
+        Call the wildcard command.
+
+        :param command: The command that was sent. If None, then it is just
+            random data.
+        :type command: str, optional
+        :param content: The data to pass to the wildcard command. Will be
+            type-casted accordingly.
+        :type content: bytes
+        :param client_data: The client data. If None, then there is no client data.
+        :type client_data: dict, optional
+
+        :raises FunctionNotFoundException: If there is no wildcard listener.
+        """
+
+        try:
+            wildcard_func = self.funcs["*"]
+        except KeyError:
+            raise FunctionNotFoundException(
+                "A wildcard function doesn't exist."
+            ) from None
+
+        arguments = []
+        if client_data is not None:
+            arguments.append(self._type_cast_client_data("*", client_data))
+
+        arguments += [
+            _type_cast(
+                type_cast=wildcard_func["type_hint"]["command"],
+                content_to_type_cast=command,
+                func_name="<wildcard function> <command>",
+            ),
+            _type_cast(
+                type_cast=wildcard_func["type_hint"]["message"],
+                content_to_type_cast=content,
+                func_name="<wildcard function> <data>",
+            ),
+        ]
+
+        self._call_function(
+            "*",
+            *arguments,
+        )
+
+    def _call_function_reserved(self, reserved_func_name: str, *args, **kwargs):
+        """
+        Call a reserved function. If the function is overridden or not found,
+        then the function will not be called.
+
+        :param reserved_func_name: The name of the reserved function to call.
+        :type reserved_func_name: str
+        :param args: The arguments to pass to the function.
+        :param kwargs: The keyword arguments to pass to the function.
+        """
+
+        if (
+            reserved_func_name not in self._reserved_funcs
+            or reserved_func_name not in self.funcs
+            # This shouldn't happen, because if it is overridden then it should already
+            # be deleted from the reserved functions dictionary. But just in case the user
+            # manually changed the dictionary or something...
+            or self.funcs[reserved_func_name]["override"]
+        ):
+            return
+
+        # Not going to verify if the amount of args and kwargs are correct, because
+        # that should've already been done
+        self.funcs[reserved_func_name]["func"](*args, **kwargs)
+
+    def _call_function(self, func_name: str, *args, **kwargs):
+        """
+        Calls a function with the given arguments.
+
+        :param func_name: The name of the function to call.
+        :type func_name: str
+        :param args: The arguments to pass to the function.
+        :param kwargs: The keyword arguments to pass to the function.
+
+        :raises FunctionNotFoundException: If the function is not found.
+        """
+
+        if func_name not in self.funcs:
+            raise FunctionNotFoundException(
+                f"Function with command {func_name} not found"
+            )
+
+        # Normal
+        if not self.funcs[func_name]["threaded"]:
+            self.funcs[func_name]["func"](*args, **kwargs)
+            return
+
+        # Threaded
+        function_thread = threading.Thread(
+            target=self.funcs[func_name]["func"],
+            args=args,
+            kwargs=kwargs,
+            daemon=True,
+        )
+        function_thread.start()
+
+    class _on:  # NOSONAR (it's used in child classes)
+        """Decorator for handling a command"""
+
+        def __init__(
+            self,
+            outer,
+            command: str,
+            threaded: bool,
+            override: bool,
+        ):
+            self.outer = outer
+            self.command = command
+            self.threaded = threaded
+            self.override = override
+
+            validate_command_not_reserved(self.command)
+
+        def __call__(self, func: Callable) -> Callable:
+            """
+            Adds a function that gets called when the server receives a matching command.
+
+            :raises ValueError: If the number of function arguments is invalid.
+            """
+
+            func_args = inspect.getfullargspec(func).args
+
+            # Overriding a reserved command, remove it from reserved functions
+            if self.override and self.command in self.outer._reserved_funcs:
+                self.outer.funcs.pop(self.command, None)
+                del self.outer._reserved_funcs[self.command]
+
+            self._assert_num_func_args_valid(len(func_args))
+
+            # Store annotations of function
+            annotations = _str_type_to_type_annotations_dict(
+                inspect.getfullargspec(func).annotations
+            )  # {"param": type}
+            parameter_annotations = {}
+
+            # Map function arguments into type hint compliant ones
+            type_cast_arguments: tuple
+            if self.command in self.outer._reserved_funcs:
+                type_cast_arguments = (
+                    self.outer._reserved_funcs[self.command]["type_cast_arguments"],
+                )[0]
+            else:
+                type_cast_arguments = self.outer._unreserved_func_arguments
+
+            for func_argument, argument_name in zip(func_args, type_cast_arguments):
+                parameter_annotations[argument_name] = annotations.get(
+                    func_argument, None
+                )
+
+            # Add function
+            self.outer.funcs[self.command] = {
+                "func": func,
+                "name": func.__name__,
+                "type_hint": parameter_annotations,
+                "threaded": self.threaded,
+                "override": self.override,
+            }
+
+            # Decorator stuff
+            return func
+
+        def _assert_num_func_args_valid(self, number_of_func_args: int):
+            """
+            Asserts the number of function arguments is valid.
+            Unreserved commands can have either 0 or 1 arguments.
+            For reserved commands, refer to
+            :ivar:`outer._reserved_funcs`.
+
+            :raises TypeError: If the number of function arguments is invalid.
+            """
+
+            needed_number_of_args = f"0-{len(self.outer._unreserved_func_arguments)}"
+
+            # Reserved commands
+            if self.command in self.outer._reserved_funcs:
+                needed_number_of_args = (
+                    self.outer._reserved_funcs[self.command]["number_arguments"],
+                )[0]
+                valid = number_of_func_args == needed_number_of_args
+
+            # Unreserved commands
+            else:
+                valid = number_of_func_args <= len(
+                    self.outer._unreserved_func_arguments
+                )
+
+            if not valid:
+                raise TypeError(
+                    f"{self.command} command must have {needed_number_of_args} "
+                    f"arguments, not {number_of_func_args}"
+                )
+
+    # Transmitting data
+
+    def _handle_recv_commands(self, command: str, content: bytes):
+        """
+        Handle data needed for :meth:`recv`
+        Should be called from update or run.
+
+        :param command: The command to handle.
+        :type command: str
+        :param content: The content / data / message.
+        :type content: bytes
+
+        :return: If there was a response.
+        :rtype: bool
+        """
+
+        for listener in self._recv_on_events:
+            # Catch-all listeners
+            # `listener` transverses in-order, so the first will be the minimum
+            should_continue = True
+            if listener.startswith("$") and listener.endswith("$"):
+                should_continue = False
+            # Specific listeners
+            if listener == command:
+                should_continue = False
+            if should_continue:
+                continue
+
+            self._recv_on_events[listener]["data"] = content
+            self._recv_on_events[listener]["thread_event"].set()
+            return True
+
+        return False
+
+    def recv(self, recv_on: str = None, recv_as: Sendable = bytes) -> Sendable:
+        """
+        Receive data from the server while blocking.
+        Can receive on a command, which is used as like one-time on decorator.
+
+        .. note::
+           Reserved functions will be ignored and not caught by this method.
+
+        :param recv_on: A string for the command to receive on.
+        :type recv_on: str, optional
+        :param recv_as: The type to receive the data as.
+        :type recv_as: Sendable, optional
+
+        :return: The data received type casted as ``recv_as``.
+        :rtype: Sendable
+        """
+
+        # `update` will be the one actually receiving the data (in its own thread).
+        # Tell update to listen for a command and send it to us instead.
+        if recv_on is not None:
+            listen_on = recv_on
+        else:
+            # Get the highest number of catch-all listeners
+            catch_all_listener_max = 0
+            for listener in self._recv_on_events:
+                if not listener.startswith("$") and listener.endswith("$"):
+                    continue
+                catch_all_listener_max = int(listener.replace("$", ""))
+
+            listen_on = f"${catch_all_listener_max + 1}$"
+
+        # {event_name: {"thread_event": threading.Event, "data": Union[None, bytes]}}
+        self._recv_on_events[listen_on] = {
+            "thread_event": threading.Event(),
+            "data": None,
+        }
+
+        # Wait for `update` to retreive the data
+        self._recv_on_events[listen_on]["thread_event"].wait()
+
+        # Clean up
+        data = self._recv_on_events[listen_on]["data"]
+        del self._recv_on_events[listen_on]
+
+        # Return
+        return _type_cast(
+            type_cast=recv_as, content_to_type_cast=data, func_name="<recv function>"
+        )
diff --git a/hisock/client.py b/hisock/client.py
index 6673286..016b023 100644
--- a/hisock/client.py
+++ b/hisock/client.py
@@ -13,40 +13,52 @@
 # Imports
 from __future__ import annotations  # Remove when 3.10 is used by majority
 
-import builtins  # Builtins, to convert string methods into builtins
-import inspect  # Inspect, for type-hinting detection
-import re  # regex, to make sure that arguments are correct
-import socket  # socket, because.... bruh
-import json  # json, to communicate to server (without 10000 commands)
-import errno  # errno, to communicate to server
-import sys  # sys
-import threading  # threading, for ThreadedHiSockClient and threaded decorator parameter
-import traceback  # traceback, for... tracebacks
-from ipaddress import IPv4Address  # ipaddress, for comparisons between IPs
-
-from typing import Union, Callable
-
-# Utilities
+import socket
+import json  # Handle sending dictionaries
+import errno  # Handle fatal errors with the server
+import sys  # Utilize stderr
+import threading  # Threaded client and decorators
+import traceback  # Error handling
+from typing import Callable, Union  # Type hints
+from ipaddress import IPv4Address  # Comparisons
+from time import time  # Unix timestamp support
+
 try:
-    # use relative import for pip builds (required)
+    # Pip builds require relative import
     from .utils import (
-        make_header,
-        _removeprefix,
+        ClientException,
+        ClientNotFound,
+        ServerException,
         ServerNotRunning,
-        iptup_to_str,
-        _type_cast,
         MessageCacheMember,
+        ClientInfo,
+        Sendable,
+        Client,
+        _removeprefix,
+        _type_cast,
+        make_header,
+        iptup_to_str,
+        validate_ipv4,
     )
+    from ._shared import _HiSockBase
 except ImportError:
-    # relative import doesn't work for non-pip builds
+    # Relative import doesn't work for non-pip builds
     from utils import (
-        make_header,
-        _removeprefix,
+        ClientException,
+        ClientNotFound,
+        ServerException,
         ServerNotRunning,
-        iptup_to_str,
-        _type_cast,
         MessageCacheMember,
+        ClientInfo,
+        Sendable,
+        Client,
+        _removeprefix,
+        _type_cast,
+        make_header,
+        iptup_to_str,
+        validate_ipv4,
     )
+    from _shared import _HiSockBase
 
 
 # ░█████╗░░█████╗░██╗░░░██╗████████╗██╗░█████╗░███╗░░██╗██╗
@@ -55,764 +67,809 @@
 # ██║░░██╗██╔══██║██║░░░██║░░░██║░░░██║██║░░██║██║╚████║╚═╝
 # ╚█████╔╝██║░░██║╚██████╔╝░░░██║░░░██║╚█████╔╝██║░╚███║██╗
 # ░╚════╝░╚═╝░░╚═╝░╚═════╝░░░░╚═╝░░░╚═╝░╚════╝░╚═╝░░╚══╝╚═╝
-#      Change the above code IF and only IF you know
-#                  what you are doing!
+#   Change this code only if you know what you are doing!
+# If this code is changed, the client may not work properly
 
 
-class HiSockClient:
+class HiSockClient(_HiSockBase):
     """
-    The client class for hisock.
-    HiSockClient offers a higher-level version of sockets. No need to worry about headers now, yay!
-    HiSockClient also utilizes decorators to receive messages, as an easy way of organizing your
-    code structure (methods are provided, like :func:`recv_raw`, of course)
+    The client class for :mod:`HiSock`.
 
     :param addr: A two-element tuple, containing the IP address and the
-        port number of the server wishing to connect to
-        Only IPv4 currently supported
+        port number of where the server is hosted.
+        **Only IPv4 is currently supported.**
     :type addr: tuple
     :param name: Either a string or NoneType, representing the name the client
-        goes by. Having a name provides an easy interface of sending
+        goes by. Having a name provides an easy interface of sending.
         data to a specific client and identifying clients. It is therefore
-        highly recommended to pass in a name
+        highly recommended to pass in a name.
 
-        Pass in NoneType for no name (`connect` should handle that)
+        Pass in NoneType for no name (:meth:`connect` should handle that)
     :type name: str, optional
-    :param group: Either a string or NoneType, representing the group the client
+    :param group: Either a string or NoneType representing the group the client
         is in. Being in a group provides an easy interface of sending
         data to multiple specific clients, and identifying multiple clients.
-        It is highly recommended to provide a group for complex servers
-
-        Pass in NoneType for no group (`connect` should handle that)
+        It is highly recommended to provide a group for complex servers.
+        Pass in NoneType for no group (:meth:`connect` should handle that).
     :type group: str, optional
-    :param blocking: A boolean, set to whether the client should block the loop
-        while waiting for message or not.
-        Default sets to True
-    :type blocking: bool, optional
-    :param header_len: An integer, defining the header length of every message.
-        A smaller header length would mean a smaller maximum message
-        length (about 10**header_len).
-        The header length MUST be the same as the server connecting, or it will
-        crash (hard to debug though).
-        Default sets to 16 (maximum length of content: 10 quadrillion bytes)
+    :param header_len: An integer defining the header length of every message.
+        A larger header length would mean a larger maximum message length
+        (about 10**header_len).
+        **MUST** be the same header length as the server, or else it will crash
+        (hard to debug too!).
+        Default sets to 16 (maximum length of content: 10 quadrillion bytes).
     :type header_len: int, optional
-
-    :ivar tuple addr: A two-element tuple, containing the IP address and the
-        port number
-    :ivar int header_len: An integer, storing the header length of each "message"
-    :ivar str name: A string, representing the name of the client to identify by.
-        Defaults to None
-    :ivar str group: A string, representing the group of the client to identify by.
-        Defaults to None
+    :param cache_size: The size of the message cache.
+        -1 or below for no message cache, 0 for an unlimited cache size,
+        and any other number for the cache size.
+    :type cache_size: int, optional
+
+    :ivar tuple addr: A two-element tuple containing the IP address and the
+        port number of the server.
+    :ivar int header_len: An integer storing the header length of each "message".
+    :ivar str name: A string representing the name of the client to identify by.
+        Default is None.
+    :ivar str group: A string representing the group of the client to identify by.
+        Default is None.
     :ivar dict funcs: A list of functions registered with decorator :meth:`on`.
-
-        .. warning::
-
-           This is mainly used for under-the-hood-code, so it is **NOT** recommended
-           to be used in production-ready code
+        **This is mainly used for under-the-hood-code.**
+    :ivar int connect_time: An integer sotring the Unix timestamp of when the
+        client connected to the server.
     """
 
     def __init__(
         self,
         addr: tuple[str, int],
-        name: Union[str, None],
-        group: Union[str, None],
-        blocking: bool = True,
+        name: Union[str, None] = None,
+        group: Union[str, None] = None,
         header_len: int = 16,
         cache_size: int = -1,
     ):
-        # Function and cache storage
-        self.funcs = {}
-        self.cache_size = cache_size
-        if cache_size >= 0:
-            # cache_size <= -1: No cache
-            self.cache = []
-
-        # Info for socket
-        self.addr = addr
+        super().__init__(addr=addr, header_len=header_len, cache_size=cache_size)
+
         self.name = name
         self.group = group
-        self.header_len = header_len
-
-        # Flags
-        self.closed = False
 
-        # Remember to update them as more rev funcs are added
-        self.reserved_functions = ["client_connect", "client_disconnect"]
+        self.original_name = name
+        self.original_group = group
 
-        # Socket intialization
+        # Socket initialization
         self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
         try:
             self.sock.connect(self.addr)
         except ConnectionRefusedError:
-            # Server not running
-            raise ServerNotRunning(
-                "Server is not running! Aborting..."
-            ) from ConnectionRefusedError
+            raise ServerNotRunning("Server is not running! Aborting...") from None
+        self.sock.setblocking(True)
+
+        # Stores the names of the reserved functions and information about them
+        self._reserved_funcs = {
+            "client_connect": {
+                "number_arguments": 1,
+                "type_cast_arguments": ("client_data",),
+            },
+            "client_disconnect": {
+                "number_arguments": 1,
+                "type_cast_arguments": ("client_data",),
+            },
+            "force_disconnect": {
+                "number_arguments": 0,
+                "type_cast_arguments": (),
+            },
+            "*": {"number_arguments": 2, "type_cast_arguments": ("command", "message")},
+        }
+        self._unreserved_func_arguments = ("message",)
 
-        self.sock.setblocking(blocking)
+        # Flags
+        self.connected = False
+        self.connect_time = 0  # Unix timestamp
 
         # Send client hello
-        hello_dict = {"name": self.name, "group": self.group}
-        conn_header = make_header(
-            f"$CLTHELLO$ {json.dumps(hello_dict)}", self.header_len
-        )
+        self._send_client_hello()
 
-        self.sock.send(conn_header + f"$CLTHELLO$ {json.dumps(hello_dict)}".encode())
+    # Dunders
 
     def __str__(self) -> str:
-        """Example: <HiSockClient connected to 192.168.1.133:33333"""
+        """Example: <HiSockClient connected to 192.168.1.133:5000>"""
+
         return f"<HiSockClient connected to {iptup_to_str(self.addr)}>"
 
+    def __repr__(self):
+        return self.__str__()
+
+    # Comparisons
+
     def __gt__(self, other: Union[HiSockClient, str]) -> bool:
-        """Example: HiSockClient(...) > '192.168.1.131'"""
-        if type(other) not in [HiSockClient, str]:
+        """Example: HiSockClient(...) > "192.168.1.133:5000" """
+
+        if type(other) not in (HiSockClient, str):
             raise TypeError("Type not supported for > comparison")
         if isinstance(other, HiSockClient):
             return IPv4Address(self.addr[0]) > IPv4Address(other.addr[0])
-        ip = other.split(":")  # Gets rid of ports
-
+        ip = other.split(":")
         return IPv4Address(self.addr[0]) > IPv4Address(ip[0])
 
     def __ge__(self, other: Union[HiSockClient, str]) -> bool:
-        """Example: HiSockClient(...) >= '192.168.1.131'"""
-        if type(other) not in [HiSockClient, str]:
+        """Example: HiSockClient(...) >= "192.168.1.133:5000" """
+
+        if type(other) not in (HiSockClient, str):
             raise TypeError("Type not supported for >= comparison")
         if isinstance(other, HiSockClient):
             return IPv4Address(self.addr[0]) >= IPv4Address(other.addr[0])
-        ip = other.split(":")  # Gets rid of ports
-
+        ip = other.split(":")
         return IPv4Address(self.addr[0]) >= IPv4Address(ip[0])
 
     def __lt__(self, other: Union[HiSockClient, str]) -> bool:
-        """Example: HiSockClient(...) < '192.168.1.131'"""
-        if type(other) not in [HiSockClient, str]:
+        """Example: HiSockClient(...) < "192.168.1.133:5000" """
+
+        if type(other) not in (HiSockClient, str):
             raise TypeError("Type not supported for < comparison")
         if isinstance(other, HiSockClient):
             return IPv4Address(self.addr[0]) < IPv4Address(other.addr[0])
-        ip = other.split(":")  # Gets rid of ports
-
+        ip = other.split(":")
         return IPv4Address(self.addr[0]) < IPv4Address(ip[0])
 
     def __le__(self, other: Union[HiSockClient, str]) -> bool:
-        """Example: HiSockClient(...) <= '192.168.1.131'"""
-        if type(other) not in [HiSockClient, str]:
+        """Example: HiSockClient(...) <= "192.168.1.133:5000" """
+
+        if type(other) not in (HiSockClient, str):
             raise TypeError("Type not supported for <= comparison")
         if isinstance(other, HiSockClient):
             return IPv4Address(self.addr[0]) <= IPv4Address(other.addr[0])
-        ip = other.split(":")  # Gets rid of ports
-
+        ip = other.split(":")
         return IPv4Address(self.addr[0]) <= IPv4Address(ip[0])
 
     def __eq__(self, other: Union[HiSockClient, str]) -> bool:
-        """Example: HiSockClient(...) == '192.168.1.131'"""
-        if type(other) not in [HiSockClient, str]:
+        """Example: HiSockClient(...) == "192.168.1.133:5000" """
+
+        if type(other) not in (HiSockClient, str):
             raise TypeError("Type not supported for == comparison")
         if isinstance(other, HiSockClient):
             return IPv4Address(self.addr[0]) == IPv4Address(other.addr[0])
-        ip = other.split(":")  # Gets rid of ports
-
+        ip = other.split(":")
         return IPv4Address(self.addr[0]) == IPv4Address(ip[0])
 
-    class _on:
-        """Decorator used to handle something when receiving command"""
+    # Internal methods
 
-        def __init__(self, outer: HiSockClient, command: str, threaded: bool = False):
-            # `outer` arg is for the HiSockClient instance
-            # `cmd_activation` is the command... on activation (WOW)
-            self.outer = outer
-            self.command = command
-            self.threaded = threaded
-
-        def __call__(self, func: Callable) -> Callable:
-            """Adds a function that gets called when the client receives a matching command"""
+    def _send_client_hello(self):
+        """
+        Sends a hello to the server for the first connection.
 
-            # Checks for illegal $cmd$ notation (used for reserved functions)
-            if re.search(r"\$.+\$", self.command):
-                raise ValueError(
-                    'The format "$command$" is used for reserved functions - '
-                    "Consider using a different format"
-                )
-            # Gets annotations of function
-            annots = inspect.getfullargspec(func).annotations
-            func_args = inspect.getfullargspec(func).args
+        :raises ClientException: If the client is already connected.
+        """
 
-            try:
-                # Try to map first arg (client data)
-                # Into type hint compliant one
-                msg_annotation = annots[func_args[0]]
-                if isinstance(msg_annotation, str):
-                    msg_annotation = builtins.__dict__[annots[func_args[0]]]
-            except KeyError:
-                # builtins is not what we thought; I dunno why I did this
-                msg_annotation = None
-            except IndexError:
-                msg_annotation = None
-
-            # Creates function dictionary to add to `outer.funcs`
-            func_dict = {
-                "func": func,  # Function
-                "name": func.__name__,  # Function name
-                "type_hint": msg_annotation,  # All function type hints,
-                "threaded": self.threaded,
-            }
-            self.outer.funcs[self.command] = func_dict
-
-            # Returns the inner function, like a decorator
-            return func
-
-    def _call_function(self, func_name, *args, **kwargs):
-        if not self.funcs[func_name]["threaded"]:
-            self.funcs[func_name]["func"](*args, **kwargs)
-        else:
-            function_thread = threading.Thread(
-                target=self.funcs[func_name]["func"], args=args, kwargs=kwargs
+        if self.connected:
+            raise ClientException(
+                f"Client is already connected! (connected {time() - self.connect_time} seconds ago)"
             )
-            function_thread.setDaemon(True)  # FORGIVE ME PEP 8 FOR I HAVE SINNED
-            function_thread.start()
 
-    def on(self, command: str, threaded: bool = False) -> Callable:
+        hello_dict = {"name": self.name, "group": self.group}
+        self._send_raw(f"$CLTHELLO${json.dumps(hello_dict)}")
+
+        self.connected = True
+        self.connect_time = time()
+
+    def _handle_keepalive(self):
+        """Handle a keepalive sent from the server."""
+
+        self._send_raw("$KEEPACK$")
+
+    # On decorator
+
+    def on(
+        self, command: str, threaded: bool = False, override: bool = False
+    ) -> Callable:
         """
         A decorator that adds a function that gets called when the client
-        receives a matching command
+        receives a matching command.
 
         Reserved functions are functions that get activated on
-        specific events. Currently, there are 2 for HiSockClient:
-
-        1. client_connect - Activated when a client connects to the server
+        specific events, and they are:
 
-        2. client_disconnect - Activated when a client disconnects from the server
+        1. ``client_connect`` - Activated when a client connects to the server
+        2. ``client_disconnect`` - Activated when a client disconnects from the server
 
         The parameters of the function depend on the command to listen.
-        For example, reserved functions `client_connect` and
-        `client_disconnect` gets the client's data passed in as an argument.
-        All other nonreserved functions get the message passed in.
+        For example, reserved functions ``client_connect`` and
+        ``client_disconnect`` gets the client's data passed in as an argument.
+        All other unreserved functions get the message passed in.
 
-        In addition, certain type casting is available to nonreserved functions.
+        In addition, certain type casting is available to unreserved functions.
         That means, that, using type hints, you can automatically convert
         between needed instances. The type casting currently supports:
 
-        1. bytes -> int (Will raise exception if bytes is not numerical)
-
-        2. bytes -> float (Will raise exception if bytes is not numerical)
-
-        3. bytes -> str (Will raise exception if there's a unicode error)
+        - ``bytes``
+        - ``str``
+        - ``int``
+        - ``float``
+        - ``bool``
+        - ``None``
+        - ``list`` (with the types listed here)
+        - ``dict`` (with the types listed here)
 
-        Type casting for reserved commands is scheduled to be
-        implemented, and is currently being worked on.
+        For more information, read the documentation for type casting.
 
         :param command: A string, representing the command the function should activate
-            when receiving it
+            when receiving it.
         :type command: str
         :param threaded: A boolean, representing if the function should be run in a thread
-            in order to not block the update() loop.
-
-            Defaults to False
+            in order to not block the update loop.
+            Default is False.
         :type threaded: bool, optional
+        :param override: A boolean representing if the function should override the
+            reserved function with the same name and to treat it as an unreserved function.
+            Default is False.
+        :type override: bool, optional
 
-        :return: The same function
-            (The decorator just appended the function to a stack
+        :return: The same function (the decorator just appended the function to a stack).
         :rtype: function
+
+        :raises TypeError: If the number of function arguments is invalid.
         """
-        # Passes in outer to _on decorator/class
-        return self._on(self, command, threaded)
 
-    def update(self):
+        return self._on(self, command, threaded, override)
+
+    # Getters
+    def get_cache(
+        self,
+        idx: Union[int, slice, None] = None,
+    ) -> list[MessageCacheMember]:
         """
-        Handles newly received messages, excluding the received messages for `wait_recv`.
-        This method must be called every iteration of a while loop, as to not lose valuable info. 
-        This is also called underhood in :meth:`start`.
+        Gets the message cache.
+
+        :param idx: An integer or ``slice``, specifying what specific message caches to return.
+            Default is None (retrieves the entire cache).
+        :type idx: Union[int, slice], optional
+
+        :return: A list of dictionaries, representing the cache
+        :rtype: list[dict]
         """
 
-        if self.closed:  # Checks if client hasn't been closed with `close`
-            return
+        if idx is None:
+            return self.cache
 
-        try:
-            while not self.closed:
-                # Receives header - If doesn't exist, server error
-                try:
-                    content_header = self.sock.recv(self.header_len)
-                except ConnectionResetError:
-                    # Raise ServerNotRunning exception FROM ConnectionResetError
-                    raise ServerNotRunning(
-                        "Server has stopped running, aborting..."
-                    ) from ConnectionResetError
-                except ConnectionAbortedError:
-                    # Keepalive timeout reached
-                    self.closed = True
-                    continue
+        return self.cache[idx]
 
-                if not content_header:
-                    # Most likely server error; aborts
-                    print(
-                        "[SERVER] Connection forcibly closed by server, exiting..."
-                    )
-                    raise SystemExit
-                content = self.sock.recv(int(content_header.decode()))
-
-                for matching in self.funcs:
-                    if re.search(r"\$.+\$", matching):
-                        raise ValueError(
-                            'The format "$command$" is used for reserved functions - '
-                            "Consider using a different format\n"
-                            f'(Found with function "{matching}"'
-                        )
-
-                # Handle "reserved functions"
-                if content == b"$DISCONN$":
-                    self.close()
-
-                    if "force_disconnect" in self.funcs:
-                        self._call_function("force_disconnect")
-                if content == b"$KEEPALIVE$":
-                    response = make_header(b"$KEEPACK$", self.header_len)
-                    self.sock.send(response + b"$KEEPACK$")
-                if content == b"$DISCONNKEEP":
-                    print("AMOGUS")
-                if (
-                    content.startswith(b"$CLTCONN$")
-                    and "client_connect" in self.funcs
-                ):
-                    # Client connected to server; parse and call function
-                    clt_content = json.loads(_removeprefix(content, b"$CLTCONN$ "))
-                    self._call_function("client_connect", clt_content)
-                elif (
-                    content.startswith(b"$CLTDISCONN$")
-                    and "client_disconnect" in self.funcs
-                ):
-                    # Client disconnected from server; parse and call function
-                    clt_content = json.loads(
-                        _removeprefix(content, b"$CLTDISCONN$ ")
-                    )
-                    self._call_function("client_disconnect", clt_content)
-
-                has_corresponding_function = False
-                parse_content = None  # FINE pycharm
-                command = None
-
-                for matching_cmd, func in self.funcs.items():
-                    # Loop through functions and binded commands
-                    if (
-                        content.startswith(matching_cmd.encode())
-                        and matching_cmd not in self.reserved_functions
-                    ):
-                        has_corresponding_function = True
-                        command = matching_cmd
-                        parse_content = content[len(matching_cmd) + 1 :]
-
-                        # Type Hint -> Type Cast
-                        # (Exceptions need to have "From ValueError")
-                        parse_content = _type_cast(
-                            func["type_hint"], parse_content, func
-                        )
-
-                        # Call function
-                        if not func["threaded"]:
-                            func["func"](parse_content)
-                        else:
-                            function_thread = threading.Thread(
-                                target=func["func"], args=(parse_content,)
-                            )
-                            function_thread.setDaemon(
-                                True
-                            )  # FORGIVE ME PEP 8 FOR I HAVE SINNED
-                            function_thread.start()
-
-                        continue
-
-                # Caching
-                if self.cache_size >= 0:
-                    if has_corresponding_function:
-                        cache_content = parse_content  # Bruh pycharm it DOES exist if hcf is True
-                    else:
-                        cache_content = content
-                    self.cache.append(
-                        MessageCacheMember(
-                            {
-                                "header": content_header,
-                                "content": cache_content,
-                                "called": has_corresponding_function,
-                                "command": command,
-                            }
-                        )
-                    )
+    def get_client(
+        self, client: Client, get_as_dict: bool = False
+    ) -> Union[ClientInfo, dict]:
+        """
+        Gets the client data for a client.
+
+        :param client: The client name or IP+port to get.
+        :type client: Client
+        :param get_as_dict: A boolean representing if the client data should be
+            returned as a dictionary. Otherwise, it'll be returned as an
+            instance of :class:`ClientInfo`.
+            Default is False.
+        :type get_as_dict: bool, optional
+
+        :return: The client data.
+        :rtype: Union[ClientInfo, dict]
+
+        :raises ValueError: If the client IP is invalid.
+        :raises ClientNotFound: If the client couldn't be found.
+        :raises ServerException: If another error occurred.
+        """
 
-                    if 0 < self.cache_size < len(self.cache):
-                        self.cache.pop(0)
+        try:
+            validate_ipv4(iptup_to_str(client))
+        except ValueError as e:
+            # Names are allowed, too.
+            if not isinstance(client, str):
+                raise e
+
+        self._send_raw(f"$GETCLT${client}")
+        response = self.recv()
+        response = _type_cast(
+            type_cast=dict,
+            content_to_type_cast=response,
+            func_name="<get_client response>",
+        )
 
-        except IOError as e:
-            # Normal, means message has ended
-            if (
-                e.errno != errno.EAGAIN
-                and e.errno != errno.EWOULDBLOCK
-                and not self.closed
-            ):
-                # Fatal Error, abort client (print exception, print log, exit python)
-                traceback.print_exception(
-                    type(e), e, e.__traceback__, file=sys.stderr
-                )
-                print(
-                    "\nServer Error encountered, aborting client...",
-                    file=sys.stderr,
-                )
-                self.close()
+        # Validate response
+        if "traceback" in response:
+            if response["traceback"] == "$NOEXIST$":
+                raise ClientNotFound(f"Client {client} not connected to the server.")
+            raise ServerException(
+                f"Failed to get client from server: {response['traceback']}"
+            )
 
-                raise SystemExit
+        # Type cast
+        if get_as_dict:
+            return response
+        return ClientInfo(**response)
 
-    def start(self):
+    def get_server_addr(self) -> tuple[str, int]:
         """
-        Starts a while loop that actually runs the client long-term. Exactly equivalent to:
+        Gets the address of where the client is connected to.
 
-        .. code-block:: python
-           while not client.closed:
-               client.update()
+        :return: A tuple, with the format (str IP, int port)
+        :rtype: tuple[str, int]
+        """
+
+        return self.addr
 
+    def get_client_addr(self) -> tuple[str, int]:
         """
-        while not self.closed:
-            self.update()
+        Gets the address of the client.
 
-    def send(
-        self,
-        command: str,
-        content: Union[
-            bytes,
-            dict[
-                Union[str, int, float, bool, None],
-                Union[str, int, float, bool, None],
-            ],
-        ],
-    ):
+        :return: A tuple, with the format (IP, port).
+        :rtype: tuple[str, int]
         """
-        Sends a command & content to the server, where it will be interpreted
+
+        return self.sock.getsockname()
+
+    # Transmit data
+
+    def send(self, command: str, content: Sendable = None):
+        """
+        Sends a command & content to the server.
 
         :param command: A string, containing the command to send
         :type command: str
-        :param content: A bytes-like object, with the content/message
-            to send
-        :type content: Union[bytes, dict]
+        :param content: The message / content to send
+        :type content: Sendable, optional
         """
-        # Creates header and sends to server
-        if re.search(r"\$.+\$", command):
-            raise TypeError(
-                'Command format "$command$" is used for reserved functions - '
-                "consider using a different command"
-            )
-        if isinstance(content, dict):
-            content = json.dumps(content).encode()
-            content_header = make_header(
-                b"$USRSENTDICT$" + command.encode() + b" " + content, self.header_len
-            )
-            self.sock.send(
-                content_header + b"$USRSENTDICT$" + command.encode() + b" " + content
-            )
-        else:
-            content_header = make_header(
-                command.encode() + b" " + content, self.header_len
-            )
-            self.sock.send(content_header + command.encode() + b" " + content)
 
-    def send_raw(
-        self,
-        content: bytes,
-    ):  # TODO: Add dict-sending support for this method
+        data_to_send = (
+            b"$CMD$" + command.encode() + b"$MSG$" + self._send_type_cast(content)
+        )
+        content_header = make_header(data_to_send, self.header_len)
+        self.sock.send(content_header + data_to_send)
+
+    def _send_raw(self, content: Sendable = None):
         """
         Sends a message to the server: NO COMMAND REQUIRED.
         This is preferable in some situations, where clients need to send
         multiple data over the server, without overcomplicating it with commands
 
-        :param content: A bytes-like object, with the content/message
-            to send
-        :type content: bytes
+        :param content: The message / content to send
+        :type content: Sendable, optional
         """
-        # Creates header and send content to server, but no command
-        if re.search(b"^\\$.+\\$", content):
-            raise TypeError(
-                'Command format "$command$" is used for reserved functions - '
-                "consider not sending a message starting with $command$"
-            )
 
-        # Send to server
-        header = make_header(content, self.header_len)
-        self.sock.send(header + content)
+        data_to_send = self._send_type_cast(content)
+        header = make_header(data_to_send, self.header_len)
+        self.sock.send(header + data_to_send)
 
-    def recv_raw(self) -> bytes:
-        """
-        Waits (blocks) until a message is sent, and returns that message.
-        This is not recommended for content with commands attached;
-        it is meant to be used alongside with :func:`HiSockServer.send_client_raw` and
-        :func:`HiSockServer.send_group_raw`
-
-        :return: A bytes-like object, containing the content/message
-          the client first receives
-        :rtype: bytes
-        """
-        # Blocks depending on your blocking settings, until message
-        msg_len = int(self.sock.recv(self.header_len).decode())
-        message = self.sock.recv(msg_len)
-
-        # Returns message
-        return message
+    # Changers
 
     def change_name(self, new_name: Union[str, None]):
         """
         Changes the name of the client
 
-        :param new_name: The new name for the client to be called
+        :param new_name: The new name for the client to be called.
+            If left blank, then the name will be reset.
         :type new_name: Union[str, None]
         """
-        if new_name is not None:
-            new_name_header = make_header(
-                b"$CHNAME$ " + new_name.encode(), self.header_len
-            )
-        else:
-            new_name_header = make_header(b"$CHNAME$", self.header_len)
-
-        # Send name change to server
-        self.sock.send(
-            new_name_header + (b"$CHNAME$ " + new_name.encode())
-            if new_name is not None
-            else b"$CHNAME$"
-        )
+
+        if new_name is None:
+            new_name = self.original_name
+
+        data_to_send = "$CHNAME$" + new_name
+        self._send_raw(data_to_send)
 
     def change_group(self, new_group: Union[str, None]):
         """
-        Changes the client's group
+        Changes the client's group.
 
-        :param new_group: The new group name of the client
+        :param new_group: The new group name for the client to be called.
+            if left blank, then the group will be reset
         :type new_group: Union[str, None]
         """
-        if new_group is not None:
-            new_group_header = make_header(
-                b"$CHGROUP$ " + new_group.encode(), self.header_len
-            )
-        else:
-            new_group_header = make_header(b"$CHGROUP$", self.header_len)
 
-        self.sock.send(
-            new_group_header + (b"$CHGROUP$ " + new_group.encode())
-            if new_group is not None
-            else b"$CHGROUP$"
-        )
+        if new_group is None:
+            new_group = self.original_group
 
-    def get_cache(
-        self,
-        idx: Union[int, slice, None] = None,
-    ) -> list[MessageCacheMember]:
-        """
-        Gets the message cache.
+        data_to_send = "$CHGROUP$" + new_group
+        self._send_raw(data_to_send)
 
-        :param idx: An integer or ``slice``, specifying what specific message caches to return.
+    # Update
 
-            Defaults to None (Retrieves the entire cache)
-        :type idx: Union[int, slice], optional
+    def _update(self):
+        """
+        Handles new messages and sends them to the appropriate functions. This method
+        should be called in a while loop in a thread. If this function isn't in its
+        own thread, then :meth:`recv` won't work.
 
-        :return: A list of dictionaries, representing the cache
-        :rtype: list[dict]
+        .. warning::
+           Don't call this method on its own; instead use :meth:`start`.
         """
-        if idx is None:
-            return self.cache
+        if self.closed:
+            # This shouldn't happen due to `start` handling it, but just in case...
+            return
 
-        return self.cache[idx]
+        try:
+            ### Receiving data ###
+
+            self._receiving_data = True
+
+            try:
+                content_header = self.sock.recv(self.header_len)
+            except ConnectionResetError:
+                raise ServerNotRunning(
+                    "Server has stopped running, aborting..."
+                ) from None
+            except ConnectionAbortedError:
+                # Keepalive timeout reached
+                self.closed = True
+                self._receiving_data = False
+                self.close(emit_leave=False)
+
+            if content_header == b"":
+                # Happens when the client is closing the connection while receiving
+                # data. The content header will be empty.
+                return
+
+            data = self.sock.recv(int(content_header.decode()))
+            self._receiving_data = False
+            if not data:
+                # Happens when the client is closing the connection while receiving
+                # data. The data will be empty.
+                return
+
+            ### Reserved commands ###
+
+            # Handle keepalive
+            if data == b"$KEEPALIVE$":
+                self._handle_keepalive()
+                return
+
+            # Handle force disconnection
+            elif data == b"$DISCONN$":
+                self.close(emit_leave=False)  # The server already knows we're gone
+
+                self._call_function_reserved("force_disconnect")
+                return
+
+            # Handle new client connection
+            elif data.startswith(b"$CLTCONN$") and "client_connect" in self.funcs:
+                client_data = self._type_cast_client_data(
+                    "client_connect",
+                    _type_cast(
+                        type_cast=dict,
+                        content_to_type_cast=_removeprefix(data, b"$CLTCONN$"),
+                        func_name="<client connect in update>",
+                    ),
+                )
+                self._call_function_reserved("client_connect", client_data)
+                return
+
+            # Handle client disconnection
+            elif data.startswith(b"$CLTDISCONN$"):
+                client_data = self._type_cast_client_data(
+                    "client_disconnect",
+                    _type_cast(
+                        type_cast=dict,
+                        content_to_type_cast=_removeprefix(data, b"$CLTDISCONN$"),
+                        func_name="<client disconnect in update>",
+                    ),
+                )
+
+                self._call_function_reserved("client_disconnect", client_data)
+                return
+
+            ### Unreserved commands ###
+
+            # Handle random data
+            elif not data.startswith(b"$CMD$") and "*" in self.funcs:
+                self._call_wildcard_function(
+                    client_data=None, command=None, content=data
+                )
+                return
+
+            has_listener = False  # For cache
+
+            # Get the command and the message
+            command = data.lstrip(b"$CMD$").split(b"$MSG$")[0].decode()
+            content = _removeprefix(data, f"$CMD${command}$MSG$".encode())
 
-    def get_client(self, client: Union[str, tuple[str, int]]):
-        """PROTOTYPE; DO NOT USE YET"""
-        if isinstance(client, tuple):
-            if len(client) == 2:
-                client = f"{client[0]}:{client[1]}"
+            # No content? (`_removeprefix` didn't do anything)
+            if not content or content == data:
+                content = None
+
+            # Call functions that are listening for this command from the `on`
+            # decorator
+            for matching_command, func in self.funcs.items():
+                if command != matching_command:
+                    continue
+
+                has_listener = True
+
+                # Call function with dynamic args
+                arguments = ()
+                if len(func["type_hint"]) == 1:
+                    arguments = (
+                        _type_cast(
+                            type_cast=func["type_hint"]["message"],
+                            content_to_type_cast=content,
+                            func_name=func["name"],
+                        ),
+                    )
+                self._call_function(matching_command, *arguments)
+                break
             else:
-                raise TypeError("Client tuple not correctly formatted")
-        get_client_header = make_header(b"$GETCLT$ " + client.encode(), self.header_len)
-        self.sock.send(get_client_header + b"$GETCLT$ " + client.encode())
+                has_listener = self._handle_recv_commands(command, content)
 
-        client = self.recv_raw()
+            # No listener found
+            if not has_listener and "*" in self.funcs:
+                # No recv and no catchall. A command and some data.
+                self._call_wildcard_function(
+                    client_data=None, command=command, content=content
+                )
 
-        print(client)
-        raise NotImplementedError("BRUH IT'S NOT IMPLEMENTED")
+            # Caching
+            self._cache(has_listener, command, content, data, content_header)
 
-    def get_server_addr(self) -> tuple[str, int]:
-        """
-        Gets the address of where the hisock client is connected
-        at.
+        except IOError as e:
+            # Normal, means message has ended
+            if not (
+                e.errno != errno.EAGAIN
+                and e.errno != errno.EWOULDBLOCK
+                and not self.closed
+            ):
+                return
 
-        :return: A tuple, with the format (str IP, int port)
-        :rtype: tuple[str, int]
-        """
-        return self.addr
+            # Fatal error, abort client
+            traceback.print_exception(type(e), e, e.__traceback__, file=sys.stderr)
+            print(
+                "\nServer error encountered, aborting client...",
+                file=sys.stderr,
+            )
+            self.close()
 
-    def get_client_addr(self) -> tuple[str, int]:
-        """
-        Gets the address of the hisock client that is connected
-        to the server.
+            raise SystemExit from e
 
-        :return: A tuple, with the format (str IP, int port)
-        :rtype: tuple[str, int]
-        """
-        return self.sock.getsockname()
+    # Stop
 
     def close(self, emit_leave: bool = True):
         """
-        Closes the client; running `client.update()` won't do anything now
+        Closes the client; running ``client.update()`` won't do anything now
 
         :param emit_leave: Decides if the client will emit `leave` to the server or not
         :type emit_leave: bool
         """
-        # Changes _closed flag to True to prevent
-        # `update` being crazy
+
         self.closed = True
         if emit_leave:
-            close_header = make_header(b"$USRCLOSE$", self.header_len)
-            self.sock.send(close_header + b"$USRCLOSE$")
+            try:
+                self._send_raw("$USRCLOSE$")
+            except OSError:  # Server already closed socket
+                return
+        self.sock.shutdown(socket.SHUT_RDWR)
         self.sock.close()
 
+    # Main loop
+
+    def start(self, callback: Callable = None, error_handler: Callable = None):
+        """
+        Start the main loop for the client.
+
+        :param callback: A function that will be called every time the
+            client receives and handles a message.
+        :type callback: Callable, optional
+        :param error_handler: A function that will be called every time the
+            client encounters an error.
+        :type error_handler: Callable, optional
+        """
+
+        try:
+            while not self.closed:
+                self._update()
+                if isinstance(callback, Callable):
+                    callback()
+        except Exception as e:
+            if isinstance(error_handler, Callable):
+                error_handler(e)
+            else:
+                raise e
+
 
 class ThreadedHiSockClient(HiSockClient):
     """
-    A downside of :class:`HiSockClient` is that you need to constantly
-    :meth:`run` it in a while loop, which may block the program. Fortunately,
-    in Python, you can use threads to do two different things at once. Using
-    :class:`ThreadedHiSockClient`, you would be able to run another
-    blocking program, without ever fearing about blocking and all that stuff.
+    :class:`HiSockClient`, but running in its own thread as to not block the
+    main loop. Please note that while this is running in its own thread, the
+    event handlers will still be running in the main thread. To avoid this,
+    use the ``threaded=True`` argument for the ``on`` decorator.
 
-    .. note::
-       In some cases though, :class:`HiSockClient` offers more control than
-       :class:`ThreadedHiSockClient`, so be careful about when to use
-       :class:`ThreadedHiSockClient` over :class:`HiSockClient`
+    For documentation purposes, see :class:`HiSockClient`.
     """
 
-    def __init__(
-        self, *args, **kwargs
-    ):
+    def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        self._thread = threading.Thread(target=self.run)
-
+        self._thread: threading.Thread
         self._stop_event = threading.Event()
 
-    def stop_client(self):
-        """Stops the client"""
-        self.closed = True
+    def close(self, *args, **kwargs):
+        """
+        Closes the client. Blocks the thread until the client is closed.
+        For documentation, see :meth:`HiSockClient.close`.
+        """
+
+        super().close(*args, **kwargs)
         self._stop_event.set()
-        self.sock.close()
+        try:
+            self._thread.join()
+        except RuntimeError:
+            # Cannot join current thread
+            return
 
-    def run(self):
-        """
-        The main while loop to run the thread
+    def _start(self, callback: Callable = None, error_handler: Callable = None):
+        """Start the main loop for the threaded client."""
 
-        Refer to :class:`HiSockClient` for more details
+        def updated_callback():
+            if self._stop_event.is_set() and not self.closed:
+                self.close()
 
-        .. warning::
-           This method is **NOT** recommended to be used in an actual
-           production enviroment. This is used internally for the thread, and should
-           not be interacted with the user
+            # Original callback
+            if isinstance(callback, Callable):
+                callback()
+
+        super().start(callback=updated_callback, error_handler=error_handler)
+
+    def start(self, callback: Callable = None, error_handler: Callable = None):
+        """
+        Starts the main client loop.
+        For documentation, see :meth:`HiSockClient.start`.
         """
-        while not (self._stop_event.is_set() or self.closed):
-            try:
-                self.update()
-            except (OSError, ValueError):
-                break
 
-    def start_client(self):
-        """Starts the main server loop"""
+        self._thread = threading.Thread(
+            target=self._start, args=(callback, error_handler)
+        )
         self._thread.start()
 
-    def join(self):
-        """Waits for the thread to be killed"""
-        self._thread.join()
-
 
-def connect(addr, name=None, group=None, blocking=True, header_len=16, cache_size=-1):
+def connect(addr, name=None, group=None, header_len=16, cache_size=-1):
     """
     Creates a `HiSockClient` instance. See HiSockClient for more details
 
-    :param addr: A two-element tuple, containing the IP address and
-        the port number
+    :param addr: A two-element tuple containing the IP address and
+        the port number of the server.
     :type addr: tuple
-    :param name: A string, containing the name of what the client should go by.
-        This argument is optional
+    :param name: A string containing the name of what the client should go by.
+        This argument is optional.
     :type name: str, optional
     :param group: A string, containing the "group" the client is in.
         Groups can be utilized to send specific messages to them only.
-        This argument is optional
+        This argument is optional.
     :type group: str, optional
-    :param blocking: A boolean, specifying if the client should block or not
-        in the socket.
-
-        Defaults to True
-    :type blocking: bool, optional
-    :param header_len: An integer, defining the header length of every message.
-
-        Defaults to True
+    :param header_len: An integer defining the header length of every message.
+        Default is True.
     :type header_len: int, optional
+    :param cache_size: The size of the message cache.
+        -1 or below for no message cache, 0 for an unlimited cache size,
+        and any other number for the cache size.
+    :type cache_size: int, optional
 
-    :return: A :class:`HiSockClient` instance
+    :return: A :class:`HiSockClient` instance.
     :rtype: instance
+
+    .. note::
+        A simple way to use this function is to use :func:`utils.input_client_config`
+        which will ask you for the server IP, port, name, and group. Then, you can
+        call this function by simply doing ``connect(*input_client_config())``
     """
-    return HiSockClient(addr, name, group, blocking, header_len, cache_size)
 
+    return HiSockClient(addr, name, group, header_len, cache_size)
 
-def threaded_connect(
-    addr, name=None, group=None, blocking=True, header_len=16, cache_size=-1
-):
+
+def threaded_connect(*args, **kwargs):
     """
     Creates a :class:`ThreadedHiSockClient` instance. See :class:`ThreadedHiSockClient`
     for more details
-
     :return: A :class:`ThreadedHiSockClient` instance
     """
-    return ThreadedHiSockClient(addr, name, group, blocking, header_len, cache_size)
+    return ThreadedHiSockClient(*args, **kwargs)
 
 
 if __name__ == "__main__":
-    s = threaded_connect(
-        ("192.168.1.131", 33333),
-        name="192.168.1.121:6969",
-        group="Amogus",
-        cache_size=5,
+    print("Testing client!")
+    client = connect(
+        ("127.0.0.1", int(input("Port: "))),
+        name=input("Name: "),
+        group=input("Group: "),
     )
-    s.change_name("192.168.1.121:420")
 
-    @s.on("Joe")
-    def hehe(_):
+    # print(
+    # "The HiSock police are on to you. "
+    # "You must change your name and group before they catch you."
+    # )
+    # client.change_name(input("New name: "))
+    # client.change_group(input("New group: "))
+
+    @client.on("client_connect")
+    def on_connect(client_data):
         print(
-            "This message was sent from server after client connection\n"
-            "(Sent to every client)"
+            f"{client_data.name} has joined! "
+            f"Their IP is {iptup_to_str(client_data.ip)}. "
+            f'Their group is {client_data["group"]}.'
         )
-        s.send("Sussus", b"Some random msg I guess")
-
-    @s.on("pog")
-    def eee(msg):
-        print("Follow up message sent by server\n" "(Also sent to every client)")
-        print("Message:", msg)
-
-    @s.on("client_connect", threaded=True)
-    def please(data):
-        print(f"Client {':'.join(map(str, data['ip']))} connected :)")
-        __import__("time").sleep(10)
-
-    @s.on("client_disconnect")
-    def haha_bois(disconn_data):
-        print(f"Aww man, {':'.join(map(str, disconn_data['ip']))} disconnected :(")
-
-    @s.on("Test")
-    def test(data):
-        print("Group message received:", data)
-        print(s.get_cache()[0].content)
-        s.send("lol", {"I am": "inevitable"})
-
-    @s.on("force_disconnect")
-    def susmogus():
-        print("AAAAAAAAA DISCONNECTED :(((((((")
-
-    @s.on("dicttest")
-    def amogus(yes: dict):
-        print(f"OMG SO COOL I RECEIVED THIS DICT: Does this {yes['Does this']}")
-
-    @s.on("shrek", threaded=False)
-    def sticker(yum):
-        print("Sleeping zzz")
-        __import__("time").sleep(70)
-        print("HEHE BOIS THREADED")
-
-    @s.on("john")
-    def sus(chicken):
-        print("I'm fineeeeee, and the thread is running!")
-
-    s.start_client()
+
+    @client.on("client_disconnect", override=True)
+    def on_disconnect(leave_data: dict):
+        print(
+            f'{leave_data["name"]} disconnected from the server because {leave_data["reason"]} :('
+        )
+
+    @client.on("force_disconnect")
+    def on_force_disconnect():
+        print("You have been disconnected from the server.")
+        client.close()
+        __import__("os")._exit(0)
+
+    @client.on("message", threaded=True)
+    def on_message(message: str):
+        print(f"Message received:\n{message}")
+
+    @client.on("genocide")
+    def on_genocide():
+        print("It's time to die!")
+        client.close()
+        __import__("os")._exit(0)
+
+    @client.on("*")
+    def on_wildcard(command: str, data: str):
+        print(f"There was some unhandled data from the server. {command=}, {data=}")
+
+    def choices():
+        print(
+            "Your choices are:"
+            "\n\tsend\n\tsend_to_group\n\tchange_name\n\tchange_group\n\tset_timer\n\tstop"
+            "\n\tgenocide\n\tsend_random_data"
+        )
+        while True:
+            choice = input("What would you like to do? ")
+            if choice == "send":
+                client.send("broadcast_message", input("Message: "))
+            elif choice == "send_to_group":
+                client.send("broadcast_message_to_group", input("Message: "))
+            elif choice == "ping":
+                client.send("ping")
+                ping_time = time()
+                client.recv("pong")
+                print(f"Pong! Took {time() - ping_time} seconds.")
+            elif choice == "change_name":
+                client.change_name(input("New name: "))
+            elif choice == "change_group":
+                client.change_group(input("New group: "))
+            elif choice == "set_timer":
+                client.send("set_timer", input("Seconds: "))
+                client.recv("timer_done")
+                print("Timer done!")
+            elif choice == "get_all_clients":
+                client.send("get_all_clients")
+                print(client.recv("all_clients", recv_as=dict))
+            elif choice == "stop":
+                client.close()
+                return
+            elif choice == "genocide":
+                input("You will kill many people. Do you wish to proceed? ")
+                print("Just kidding, your input had no effect. Time for genocide!")
+                client.send(
+                    "set_timer", input("How many seconds for the genocide to last?")
+                )
+                client.recv("timer_done")
+                print("Genociding...")
+                client.send("commit_genocide")
+            elif choice == "send_random_data":
+                print("Sending some random data...")
+                choice, randint = (
+                    __import__("random").choice,
+                    __import__("random").randint,
+                )
+                client.send(
+                    "uncaught_command",
+                    "Random data: "
+                    + "".join(
+                        [
+                            chr(choice((randint(65, 90), randint(97, 122))))
+                            for _ in range(100)
+                        ]
+                    ),
+                )
+            else:
+                print("Invalid choice.")
+
+    choices_thread = threading.Thread(target=choices, daemon=False)
+    choices_thread.start()
+
+    client.start()
diff --git a/hisock/constants.py b/hisock/constants.py
index 6bf4075..286ccf1 100644
--- a/hisock/constants.py
+++ b/hisock/constants.py
@@ -6,31 +6,8 @@
 ====================================
 """
 
-DH_P = int(
-    "744979203543192710450044053772127504096976876866819663713965877"
-    "154635147316608355777387359891950087945645228960201718656771034"
-    "552169234370127511507399666250874215805743769534992008739192474"
-    "680651620771141731072131990771020951927948189617006561712335039"
-    "092902264594631385881972045001862609230456728102640654791552148"
-    "752379240881456814568930177978785246122432912233385860498416984"
-    "359981824759931686189257036316889595303030025693582544897123013"
-    "294618698292081273989"
-)
-
-DH_N = int(
-    "168023643305348101249420393052749185876637091955904691113443192"
-    "134959410993073993312414777065740760668748076049331744148192114"
-    "017054878434726156448053468792068560166678924066429153585004421"
-    "884225229880798809091914890012885359709651794469699323279468348"
-    "048593877982590292080453915501522240806728338113669608730370753"
-    "824741273174041473802357865135913663412127531007339400063734436"
-    "441809302060356854235317793920771503538471976155447526193906228"
-    "83951365375"
-)
-
-DH_DEFAULT = {"P": DH_P, "N": DH_N}
-
-__version__ = "1.2"
+__version__ = "2.0"
+__license__ = "MIT"
 __name__ = "hisock"
 __copyright__ = "SSS-Says-Snek, 2021-present"
 __author__ = "SSS-Says-Snek"
diff --git a/hisock/server.py b/hisock/server.py
index 2402ae9..13526b2 100644
--- a/hisock/server.py
+++ b/hisock/server.py
@@ -12,42 +12,52 @@
 # Imports
 from __future__ import annotations  # Remove when 3.10 is used by majority
 
-import inspect  # Inspect, for type-hinting detection
-import socket  # Socket module, duh
-import select  # Yes, we're using select for multiple clients
-import json  # To send multiple data without 10 billion commands
-import re  # Regex, to make sure arguments are passed correctly
-import threading
-import warnings  # Warnings, for errors that aren't severe
-import builtins  # Builtins, to convert string methods into builtins
-from typing import Callable, Union  # Typing, for cool type hints
-from ipaddress import IPv4Address  # ipaddress, for conparisons with <, >, ==, etc
+import socket
+import select  # Handle multiple clients at once
+import json  # Handle sending dictionaries
+import threading  # Threaded server and decorators
+import warnings  # Non-severe errors
+from typing import Callable, Union, Iterable  # Type hints
+from ipaddress import IPv4Address  # Comparisons
 
 try:
+    # Pip builds require relative import
     from .utils import (
-        receive_message,
+        ServerException,
+        ClientException,
+        ClientNotFound,
+        GroupNotFound,
+        ClientInfo,
+        Sendable,
+        Client,
         _removeprefix,
-        make_header,
         _dict_tupkey_lookup,
-        _dict_tupkey_lookup_key,
         _type_cast,
-        MessageCacheMember,
-        ClientInfo,
-        InvalidTypeCast,
+        receive_message,
+        make_header,
+        validate_ipv4,
+        ipstr_to_tup,
     )
+    from ._shared import _HiSockBase
 except ImportError:
-    # relative import doesn't work for non-pip builds
+    # Relative import doesn't work for non-pip builds
     from utils import (
-        receive_message,
+        ServerException,
+        ClientException,
+        ClientNotFound,
+        GroupNotFound,
+        ClientInfo,
+        Sendable,
+        Client,
         _removeprefix,
-        make_header,
         _dict_tupkey_lookup,
-        _dict_tupkey_lookup_key,
         _type_cast,
-        MessageCacheMember,
-        ClientInfo,
-        InvalidTypeCast
+        receive_message,
+        make_header,
+        validate_ipv4,
+        ipstr_to_tup,
     )
+    from _shared import _HiSockBase
 
 
 # ░█████╗░░█████╗░██╗░░░██╗████████╗██╗░█████╗░███╗░░██╗██╗
@@ -56,1460 +66,1230 @@
 # ██║░░██╗██╔══██║██║░░░██║░░░██║░░░██║██║░░██║██║╚████║╚═╝
 # ╚█████╔╝██║░░██║╚██████╔╝░░░██║░░░██║╚█████╔╝██║░╚███║██╗
 # ░╚════╝░╚═╝░░╚═╝░╚═════╝░░░░╚═╝░░░╚═╝░╚════╝░╚═╝░░╚══╝╚═╝
-#      Change the above code IF and only IF you know
-#                  what you are doing!
+#   Change this code only if you know what you are doing!
+# If this code is changed, the server may not work properly
 
 
-class HiSockServer:
+class HiSockServer(_HiSockBase):
     """
-    The server class for hisock
-    HiSockServer offers a neater way to send and receive data than
-    sockets. You don't need to worry about headers now, yay!
+    The server class for :mod:`HiSock`.
 
     :param addr: A two-element tuple, containing the IP address and the
         port number of where the server should be hosted.
         Due to the nature of reserved ports, it is recommended to host the
-        server with a port number that's higher than 1023.
-        Only IPv4 currently supported
+        server with a port number that's greater than or equal to 1024.
+        **Only IPv4 is currently supported.**
     :type addr: tuple
-    :param blocking: A boolean, set to whether the server should block the loop
-        while waiting for message or not.
-        Default passed in by :meth:`start_server` is True
-    :type blocking: bool, optional
-    :param max_connections: The number of maximum connections :class:`HiSockServer` should accept, before
-        refusing clients' connections. Pass in 0 for unlimited connections.
-        Default passed in  by :meth:`start_server` is 0
+    :param max_connections: The number of maximum connections the server
+        should accept before refusing client connections. Pass in 0 for
+        unlimited connections.
+        Default passed in  by :meth:`start_server` is 0.
     :type max_connections: int, optional
     :param header_len: An integer, defining the header length of every message.
-        A smaller header length would mean a smaller maximum message
-        length (about 10**header_len).
-        Any client connecting MUST have the same header length as the server,
+        A larger header length would mean a larger maximum message length
+        (about 10**header_len).
+        Any client connecting **MUST** have the same header length as the server,
         or else it will crash.
-        Default passed in by :meth:`start_server` is 16 (maximum length: 10 quadrillion bytes)
+        Default passed in by :meth:`start_server` is 16 (maximum length: 10
+        quadrillion bytes).
     :type header_len: int, optional
     :param cache_size: The size of the message cache.
         -1 or below for no message cache, 0 for an unlimited cache size,
         and any other number for the cache size.
     :type cache_size: int, optional
     :param keepalive: A bool indicating whether a keepalive signal should be sent or not.
-
         If this is True, then a signal will be sent to every client every minute to prevent
         hanging clients in the server. The clients have thirty seconds to send back an
         acknowledge signal to show that they are still alive.
-
-        Defaults to True.
+        Default is True.
     :type keepalive: bool, optional
-    :ivar tuple addr: A two-element tuple, containing the IP address and the
-        port number
-    :ivar int header_len: An integer, storing the header length of each "message"
-    :ivar dict clients: A dictionary, with the socket as its key, and the client info as its value
-    :ivar dict clients_rev: A dictionary, with the client info as its key, and the socket as its value
-    :ivar dict funcs: A list of functions registered with decorator :meth:`on`.
-        **This is mainly used for under-the-hood-code**
 
-    .. note::
-
-       It is advised to use :meth:`get_client` or :meth:`get_all_clients` instead of
-       using :attr:`clients` and :attr:`clients_rev`
+    :ivar tuple addr: A two-element tuple containing the IP address and the port.
+    :ivar int header_len: An integer storing the header length of each "message".
+    :ivar dict clients: A dictionary with the socket as its key and the
+        client info as its value.
+    :ivar dict clients_rev: A dictionary with the client info as its key
+        and the socket as its value (for reverse lookup, up-to-date with
+        :attr:`clients`).
+    :ivar dict funcs: A list of functions registered with decorator :meth:`on`.
+        **This is mainly used for under-the-hood-code.**
 
-       Also, **only IPv4 is currently supported**
+    :raises TypeError: If the address is not a tuple.
     """
 
     def __init__(
         self,
         addr: tuple[str, int],
-        blocking: bool = True,
         max_connections: int = 0,
         header_len: int = 16,
         cache_size: int = -1,
         keepalive: bool = True,
     ):
-        # Binds address and header length to class attributes
-        self.addr = addr
-        self.header_len = header_len
+        super().__init__(addr=addr, header_len=header_len, cache_size=cache_size)
 
         # Socket initialization
-        self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
-        self.sock.setblocking(blocking)
+        self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+        self.socket.setblocking(True)
         try:
-            self.sock.bind(addr)
-        except socket.gaierror:
-            raise TypeError("Connection failed (most likely due to invalid IP)") from None
-        self.sock.listen(max_connections)
-
-        # Function related storage
-        self.funcs = {}
-        self.reserved_functions = [
-            "join",
-            "leave",
-            "message",
-            "name_change",
-            "group_change",
-        ]
-        self.cache_size = cache_size
-        if cache_size >= 0:
-            # cache_size <= -1: No cache
-            self.cache = []
-
-        # Dictionaries and Lists for client lookup
-        self._sockets_list = [self.sock]
-        self.clients = {}
-        self.clients_rev = {}
-
-        self.called_run = False
-        self.closed = False
+            self.socket.bind(addr)
+        except socket.gaierror as e:  # getaddrinfo error
+            raise TypeError("The IP address and/or port are invalid.") from e
+        self.socket.listen(max_connections)
+
+        # Stores the names of the reserved functions and information about them
+        # Used for the `on` decorator
+        self._reserved_funcs = {
+            "join": {
+                "number_arguments": 1,
+                "type_cast_arguments": ("client_data",),
+            },
+            "leave": {
+                "number_arguments": 1,
+                "type_cast_arguments": ("client_data",),
+            },
+            "message": {
+                "number_arguments": 3,
+                "type_cast_arguments": ("client_data", "command", "message"),
+            },
+            "name_change": {
+                "number_arguments": 3,
+                "type_cast_arguments": ("client_data",),
+            },
+            "group_change": {
+                "number_arguments": 3,
+                "type_cast_arguments": ("client_data",),
+            },
+            "*": {
+                "number_arguments": 3,
+                "type_cast_arguments": ("client_data", "command", "message"),
+            },
+        }
+        self._unreserved_func_arguments = ("client_data", "message")
+
+        # Dictionaries and lists for client lookup
+        self._sockets_list = [self.socket]  # Our socket will always be the first
+        # socket: {"ip": (ip, port), "name": str, "group": str}
+        self.clients: dict[socket.socket, dict] = {}
+        # ((ip: str, port: int), name: str, group: str): socket
+        self.clients_rev: dict[tuple, socket.socket] = {}
 
         # Keepalive
         self._keepalive_event = threading.Event()
         self._unresponsive_clients = []
-        self.keepalive = keepalive
+        self._keepalive = keepalive
 
-        if self.keepalive:
-            keepalive_thread = threading.Thread(target=self._keepalive_thread)
-            keepalive_thread.setDaemon(True)  # Another sin
+        if self._keepalive:
+            keepalive_thread = threading.Thread(
+                target=self._keepalive_thread, daemon=True
+            )
             keepalive_thread.start()
 
-    def __str__(self) -> str:
-        """Example: <HiSockServer serving at 192.168.1.133:33333>"""
+    def __str__(self):
+        """Example: <HiSockServer serving at 192.168.1.133:5000>"""
+
         return f"<HiSockServer serving at {':'.join(map(str, self.addr))}>"
 
-    def __gt__(self, other: Union[HiSockServer, str]) -> bool:
-        """Example: HiSockServer(...) > '192.168.1.131'"""
-        if type(other) not in [self.__class__, str]:
-            raise TypeError("Type not supported for > comparison")
+    def __repr__(self):
+        return self.__str__()
+
+    def __len__(self):
+        """Returns how many clients are connected"""
+
+        return len(self.clients)
+
+    # Comparisons
+    def __gt__(self, other: Union[HiSockServer, str]):
+        """Example: HiSockServer(...) > "192.168.1.133:5000" """
+
+        if type(other) not in (self.__class__, str):
+            raise TypeError("Type not supported for > comparison.")
         if isinstance(other, HiSockServer):
             return IPv4Address(self.addr[0]) > IPv4Address(other.addr[0])
-        ip = other.split(":")  # Gets rid of port, if there is port
-
+        ip = other.split(":")
         return IPv4Address(self.addr[0]) > IPv4Address(ip[0])
 
-    def __ge__(self, other: Union[HiSockServer, str]) -> bool:
-        """Example: HiSockServer(...) >= '192.168.1.131'"""
-        if type(other) not in [self.__class__, str]:
-            raise TypeError("Type not supported for >= comparison")
+    def __ge__(self, other: Union[HiSockServer, str]):
+        """Example: HiSockServer(...) >= "192.168.1.133:5000" """
+
+        if type(other) not in (self.__class__, str):
+            raise TypeError("Type not supported for >= comparison.")
         if isinstance(other, HiSockServer):
             return IPv4Address(self.addr[0]) >= IPv4Address(other.addr[0])
-        ip = other.split(":")  # Gets rid of port, if there is port
-
+        ip = other.split(":")
         return IPv4Address(self.addr[0]) >= IPv4Address(ip[0])
 
-    def __lt__(self, other: Union[HiSockServer, str]) -> bool:
-        """Example: HiSockServer(...) < '192.168.1.131'"""
-        if type(other) not in [self.__class__, str]:
-            raise TypeError("Type not supported for < comparison")
+    def __lt__(self, other: Union[HiSockServer, str]):
+        """Example: HiSockServer(...) < "192.168.1.133:5000" """
+
+        if type(other) not in (self.__class__, str):
+            raise TypeError("Type not supported for < comparison.")
         if isinstance(other, HiSockServer):
             return IPv4Address(self.addr[0]) < IPv4Address(other.addr[0])
-        ip = other.split(":")  # Gets rid of port, if there is port
-
+        ip = other.split(":")
         return IPv4Address(self.addr[0]) < IPv4Address(ip[0])
 
-    def __le__(self, other: Union[HiSockServer, str]) -> bool:
-        """Example: HiSockServer(...) <= '192.168.1.131'"""
-        if type(other) not in [self.__class__, str]:
-            raise TypeError("Type not supported for <= comparison")
+    def __le__(self, other: Union[HiSockServer, str]):
+        """Example: HiSockServer(...) <= "192.168.1.133:5000" """
+
+        if type(other) not in (self.__class__, str):
+            raise TypeError("Type not supported for <= comparison.")
         if isinstance(other, HiSockServer):
             return IPv4Address(self.addr[0]) <= IPv4Address(other.addr[0])
-        ip = other.split(":")  # Gets rid of port, if there is port
-
+        ip = other.split(":")
         return IPv4Address(self.addr[0]) <= IPv4Address(ip[0])
 
-    def __eq__(self, other: Union[HiSockServer, str]) -> bool:
-        """Example: HiSockServer(...) == '192.168.1.131'"""
-        if type(other) not in [self.__class__, str]:
-            raise TypeError("Type not supported for == comparison")
+    def __eq__(self, other: Union[HiSockServer, str]):
+        """Example: HiSockServer(...) == "192.168.1.133:5000" """
+
+        if type(other) not in (self.__class__, str):
+            raise TypeError("Type not supported for == comparison.")
         if isinstance(other, HiSockServer):
             return IPv4Address(self.addr[0]) == IPv4Address(other.addr[0])
-        ip = other.split(":")  # Gets rid of port, if there is port
-
+        ip = other.split(":")
         return IPv4Address(self.addr[0]) > IPv4Address(ip[0])
 
-    def __len__(self) -> int:
-        """Example: len(HiSockServer(...)) -> Num clients"""
-        return len(self.clients)
+    # Internal methods
 
-    class _on:
-        """Decorator used to handle something when receiving command"""
-
-        def __init__(
-            self, outer: HiSockServer, cmd_activation: str, threaded: bool = False
-        ):
-            # `outer` arg is for the HiSockServer instance
-            # `cmd_activation` is the command... on activation (WOW)
-            self.outer = outer
-            self.cmd_activation = cmd_activation
-            self.threaded = threaded
-
-        def __call__(self, func: Callable) -> Callable:
-            """Adds a function that gets called when the server receives a matching command"""
-
-            func_args = inspect.getfullargspec(func).args
-
-            if len(func_args) != 2 and (
-                self.cmd_activation not in self.outer.reserved_functions
-                or self.cmd_activation == "message"
-            ):
-                raise ValueError(
-                    f"Incorrect number of arguments: {len(func_args)} != 2"
-                )
+    def _new_client_connection(
+        self, connection: socket.socket, address: tuple[str, int]
+    ):
+        """
+        Handle the client hello handshake.
 
-            annots = inspect.getfullargspec(func).annotations
+        :param connection: The client socket.
+        :type connection: socket.socket
+        :param address: The client address.
+        :type address: tuple[str, int]
 
-            if (
-                self.cmd_activation not in self.outer.reserved_functions
-                or self.cmd_activation == "message"
-            ):
-                # Processes nonreserved commands and reserved `message `
+        :raises ServerException: If the client is already connected.
+        :raises ClientException: If the client disconnected or had an error.
+        """
 
-                # `func_args` looks like ['clt_data', 'msg']
-                # `annots` look like {'msg': str}
-                try:
-                    # Try to map first arg (client data)
-                    # Into type hint compliant one
-                    clt_annotation = annots[func_args[0]]
-                    if isinstance(clt_annotation, str):
-                        clt_annotation = builtins.__dict__[annots[func_args[0]]]
-                except KeyError:
-                    # KeyError means there is no type hint
-                    clt_annotation = None
-                try:
-                    # Try to map second arg (content)
-                    # Into type hint compliant one
-                    msg_annotation = annots[func_args[1]]
-                    if isinstance(msg_annotation, str):
-                        msg_annotation = builtins.__dict__[annots[func_args[1]]]
-                except KeyError:
-                    # KeyError means there is no type hint
-                    msg_annotation = None
-            else:
-                # None for now, will add support for reserved functions
-                # soon tm
-                clt_annotation = None
-                msg_annotation = None
-
-            # Creates function dictionary to add to `outer.funcs`
-            func_dict = {
-                "func": func,
-                "name": func.__name__,
-                "type_hint": {"clt": clt_annotation, "msg": msg_annotation},
-                "threaded": self.threaded,
-            }
+        if connection in self._sockets_list:
+            raise ServerException("Client already connected.")
+
+        self._sockets_list.append(connection)
+
+        # Receive the client hello
+        client_hello = receive_message(connection, self.header_len)
+        if not client_hello:
+            raise ClientException("Client disconnected or had an error.")
+        client_hello = _removeprefix(client_hello["data"], b"$CLTHELLO$")
+        try:
+            client_hello = json.loads(client_hello)
+        except json.JSONDecodeError:
+            raise ClientException("Client sent an invalid hello.") from None
+
+        client_data = {
+            "ip": address,
+            "name": client_hello["name"],
+            "group": client_hello["group"],
+        }
+        self.clients[connection] = client_data
+        self.clients_rev[
+            (
+                address,
+                client_hello["name"],
+                client_hello["group"],
+            )
+        ] = connection
 
-            self.outer.funcs[self.cmd_activation] = func_dict
+        # Send reserved command to existing clients
+        self._send_all_clients_raw(f"$CLTCONN$ {json.dumps(client_data)}".encode())
+
+        self._call_function_reserved(
+            "join",
+            self._type_cast_client_data(command="join", client_data=client_data),
+        )
+
+    def _client_disconnection(self, client_socket: socket.socket):
+        """
+        Handle a client disconnection.
+
+        :raises ClientNotFound: The client wasn't connected to the server.
+        """
 
-            # Returns inner function, like a decorator would do
-            return func
+        client_data = self.clients[client_socket]
 
-    def _force_remove(self, sock):
-        clt = self.clients[sock]["ip"]
+        if client_socket not in self._sockets_list:
+            raise ClientNotFound(f'Client "{client_socket}" is not connected.')
 
-        self._sockets_list.remove(sock)
-        del self.clients[sock]
+        try:
+            client_socket.close()
+        except OSError:
+            # Already closed
+            pass
+        self._sockets_list.remove(client_socket)
+        del self.clients[client_socket]
         del self.clients_rev[
-            next(
-                _dict_tupkey_lookup_key(
-                    clt, self.clients_rev
-                )
-            )
+            (client_data["ip"], client_data["name"], client_data["group"])
         ]
+        # Note: ``self._unresponsive_clients`` should be handled by the keepalive
 
-    def _call_function(self, func_name, *args, **kwargs):
-        if not self.funcs[func_name]["threaded"]:
-            self.funcs[func_name]["func"](*args, **kwargs)
-        else:
-            function_thread = threading.Thread(
-                target=self.funcs[func_name]["func"], args=args, kwargs=kwargs
-            )
-            function_thread.setDaemon(True)  # FORGIVE ME PEP 8 FOR I HAVE SINNED
-            function_thread.start()
+    # Keepalive
 
-    def _keepalive_thread(self):
-        keepalive_header = make_header(b"$KEEPALIVE$", self.header_len)
-        disconn_keepalive_header = make_header(b"$DISCONNKEEP$", self.header_len)
+    def _handle_keepalive(self, client_socket: socket.socket):
+        """
+        Handles a keepalive acknowledgment sent by a client.
 
+        :param client_socket: The client socket that sent the acknowledgment.
+        :type client_socket: socket.socket
+        """
+
+        if client_socket in self._unresponsive_clients:
+            self._unresponsive_clients.remove(client_socket)
+
+    def _keepalive_thread(self):
         while not self._keepalive_event.is_set():
             self._keepalive_event.wait(30)
 
-            # If statement is required, since if the event is set,
-            # wait() will finish itself and move on to the next code
+            # Send keepalive to all clients
             if not self._keepalive_event.is_set():
-                for client in self.clients:
-                    self._unresponsive_clients.append(client)
-                    client.send(keepalive_header + b"$KEEPALIVE$")
+                for client_socket, client_data in self.clients.items():
+                    if client_socket not in self.clients:
+                        continue
 
+                    self._unresponsive_clients.append(client_socket)
+                    self._send_client_raw(client_data["ip"], "$KEEPALIVE$")
+
+            # Keepalive acknowledgments will be handled in `_handle_keepalive`
             self._keepalive_event.wait(30)
 
-            # Same story
+            # Keepalive response wait is over, remove the unresponsive clients
             if not self._keepalive_event.is_set():
-                for client in self._unresponsive_clients:
-                    client.send(disconn_keepalive_header + b"$DISCONNKEEP$")
-                    client.shutdown(socket.SHUT_WR)
-                    client.close()
+                for client_socket in self._unresponsive_clients:
+                    try:
+                        self.disconnect_client(
+                            self.clients[client_socket]["ip"],
+                            force=True,
+                            call_func=True,
+                        )
+                    except KeyError:  # Client already left
+                        pass
                 self._unresponsive_clients.clear()
 
-    def on(self, command: str, threaded: bool = False) -> Callable:
+    # On decorator
+
+    def on(
+        self, command: str, threaded: bool = False, override: bool = False
+    ) -> Callable:
         """
         A decorator that adds a function that gets called when the server
-        receives a matching command
+        receives a matching command.
 
         Reserved functions are functions that get activated on
-        specific events. Currently, there are 3 for HiSockServer:
+        specific events, and they are:
 
-        1. join - Activated when a client connects to the server
-
-        2. leave - Activated when a client disconnects from the server
-
-        3. message - Activated when a client messages to the server
+        1. ``join`` - Activated when a client connects to the server
+        2. ``leave`` - Activated when a client disconnects from the server
+        3. ``message`` - Activated when a client messages to the server
+        4. ``name_change`` - Activated when a client changes its name
+        5. ``group_change`` - Activated when a client changes its group
 
         The parameters of the function depend on the command to listen.
-        For example, reserved commands `join` and `leave` have only one
-        client parameter passed, while reserved command `message` has two:
-        Client Data, and Message.
-        Other nonreserved functions will also be passed in the same
-        parameters as `message`
-
-        In addition, certain type casting is available to nonreserved functions.
+        For example, reserved commands ``join`` and ``leave`` have only one
+        client parameter passed, while reserved command ``message`` has two:
+        client data and message.
+        Other unreserved functions will also be passed in the same
+        parameters as ``message``.
+
+        In addition, certain type casting is available to both reserved and unreserved
+        functions.
         That means, that, using type hints, you can automatically convert
         between needed instances. The type casting currently supports:
 
-        1. bytes -> int (Will raise exception if bytes is not numerical)
-
-        2. bytes -> str (Will raise exception if there's a unicode error)
+        - ``bytes``
+        - ``str``
+        - ``int``
+        - ``float``
+        - ``bool``
+        - ``None``
+        - ``list`` (with the types listed here)
+        - ``dict`` (with the types listed here)
 
-        Type casting for reserved commands is scheduled to be
-        implemented, and is currently being worked on.
+        For more information, read the documentation for type casting.
 
         :param command: A string, representing the command the function should activate
-            when receiving it
+            when receiving it.
         :type command: str
         :param threaded: A boolean, representing if the function should be run in a thread
-            in order to not block the run() loop.
-
-            Defaults to False
-
-        :return: The same function (The decorator just appended the function to a stack)
+            in order to not block the run loop.
+            Default is False.
+        :type threaded: bool, optional
+        :param override: A boolean representing if the function should override the
+            reserved function with the same name and to treat it as an unreserved function.
+            Default is False.
+        :type override: bool, optional
+
+        :return: The same function (the decorator just appended the function to a stack).
         :rtype: function
-        """
-        # Passes in outer to _on decorator/class
-        return self._on(self, command, threaded)
 
-    def close(self):
+        :raises TypeError: If the number of function arguments is invalid.
         """
-        Closes the server; ALL clients will be disconnected, then the
-        server socket will be closed.
 
-        Running ``server.run()`` won't do anything now.
-        """
-        self.closed = True
-        self._keepalive_event.set()
-        self.disconnect_all_clients()
-        self.sock.close()
+        return self._on(self, command, threaded, override)
 
-    def disconnect_client(
-        self, client: Union[str, tuple, ClientInfo]
-    ):  # TODO: WILL ADD MORE DIVERSE SUPPORT FOR ARGS
-        """
-        Disconnects a specific client
-        Different formats of the client is supported. It can be:
-
-        - An IP + Port format, written as "ip:port"
-
-        - A tuple of IP+Port, written as (ip, port)
+    # Getters
 
-        - A client name, if it exists
-
-        - A ``ClientInfo`` instance
-
-        :param client: The client to disconnect. The format could be either by IP+Port,
-            a client name, or a ``ClientInfo`` instance.
-        :type client: Union[str, tuple, ClientInfo]
+    def _get_client_from_name_or_ip_port(self, client: Client) -> socket.socket:
         """
-        if isinstance(client, ClientInfo):
-            client = client.ip
+        Gets a client socket from a name or tuple in the form of (ip, port).
 
-        disconn_header = make_header(b"$DISCONN$", self.header_len)
-        if isinstance(client, tuple):
-            # Formats client IP tuple, and raises Exceptions if format's wrong
-            if len(client) == 2 and isinstance(client[0], str):
-                if re.search(r"^((\d?){3}\.){3}(\d\d?\d?)$", client[0]) and isinstance(
-                    client[1], int
-                ):
-                    client = f"{client[0]}:{client[1]}"
-                else:
-                    raise ValueError(
-                        f"Client tuple format should be ('ip.ip.ip.ip', port), not "
-                        f"{client}"
-                    )
-            else:
-                raise ValueError(
-                    f"Client tuple format should be ('ip.ip.ip.ip', port), not "
-                    f"{client}"
-                )
+        :param client: The name or tuple of the client.
+        :type client: Client
 
-        if re.search(r"^((\d?){3}\.){3}(\d\d?\d?):\d(\d?){4}$", client):
-            # Matching: 523.152.135.231:92344   Invalid IP handled by Python
-            # Try IP Address, should be unique
-            split_client = client.split(":")
-            reconstructed_client = []
+        :return: The socket of the client.
+        :rtype: socket.socket
 
-            # Checks IP correctness
-            try:
-                reconstructed_client.append(map(int, split_client[0].split(".")))
-            except ValueError:
-                raise ValueError("IP is not numerical (only IPv4 currently supported)") from None
-            try:
-                reconstructed_client.append(int(split_client[1]))
-            except ValueError:
-                raise ValueError(
-                    "Port is not numerical (only IPv4 currently supported)"
-                ) from None
+        :raises ValueError: Client format is wrong.
+        :raises ClientNotFound: Client does not exist.
+        :raises UserWarning: Using client name, and more than one client with
+            the same name is detected.
+        """
 
-            for subip in reconstructed_client[0]:
-                if not 0 <= subip < 255:
-                    raise ValueError(f"{client} is not a valid IP address")
-            if not 0 < reconstructed_client[1] < 65535:
-                raise ValueError(f"{split_client[1]} is not a valid port (1-65535)")
+        ret_client_socket: socket.socket
 
+        # Search by IPv4
+        if isinstance(client, tuple):
+            validate_ipv4(client)  # Raises ValueError if invalid
             try:
-                client_sock = next(
+                client_socket: socket.socket = next(
                     _dict_tupkey_lookup(
-                        (client.split(":")[0], reconstructed_client[1]),
+                        client,
                         self.clients_rev,
                         idx_to_match=0,
                     )
                 )
             except StopIteration:
-                raise TypeError(f"Client with IP {client} is not connected") from StopIteration
-            client_sock.send(disconn_header + b"$DISCONN$")
-        else:
-            # Try name or group
+                raise ClientNotFound(
+                    f'Client with IP "{client}" is not connected.'
+                ) from None
+            ret_client_socket = client_socket
+
+        # Search by name
+        elif isinstance(client, str):
             try:
-                mod_clients_rev = {}
-                for key, value in self.clients_rev.items():
-                    mod_key = (key[0], key[1])  # Groups shouldn't count
-                    mod_clients_rev[mod_key] = value
+                # Modify dictionary so only names are included
+                try:
+                    client = ipstr_to_tup(client)
+                except ValueError:
+                    client_sockets = list(
+                        _dict_tupkey_lookup(
+                            client,
+                            self.clients_rev,
+                            idx_to_match=1,
+                        )
+                    )
+                else:
+                    client_sockets = list(
+                        _dict_tupkey_lookup(
+                            client,
+                            self.clients_rev,
+                            idx_to_match=0,
+                        )
+                    )
 
-                client_sock = list(
-                    _dict_tupkey_lookup(client, mod_clients_rev, idx_to_match=1)
-                )
             except StopIteration:
-                raise TypeError(f'Client with name "{client}"does not exist')
+                raise TypeError(
+                    f'Client with name "{client}" does not exist.'
+                ) from None
 
-            if len(client_sock) > 1:
+            if len(client_sockets) > 1:
                 warnings.warn(
-                    f'{len(client_sock)} clients with name "{client}" detected; sending data to '
-                    f"Client with IP {':'.join(map(str, client_sock[0].getpeername()))}"
+                    f'{len(client_sockets)} clients with name "{client}" detected; sending data to '
+                    f"Client with IP {':'.join(map(str, client_sockets[0].getpeername()))}"
                 )
+            ret_client_socket = client_sockets[0]
+        else:
+            raise ValueError("Client format is wrong (must be of type tuple or str).")
 
-            client_sock[0].send(disconn_header + b"$DISCONN$")
+        if ret_client_socket is None:
+            raise ValueError("Client socket does not exist.")
 
-    def disconnect_all_clients(self, force=False):
-        """Disconnect all clients."""
-        if not force:
-            disconn_header = make_header(b"$DISCONN$", self.header_len)
-            for client in self.clients:
-                client.send(disconn_header + b"$DISCONN$")
-        else:
-            for conn in self._sockets_list:
-                conn.close()
-            self._sockets_list.clear()
-            self.clients.clear()
-            self.clients_rev.clear()
-            self._unresponsive_clients.clear()
-
-    def send_all_clients(
-        self,
-        command: str,
-        content: Union[
-            bytes,
-            dict[
-                Union[str, int, float, bool, None], Union[str, int, float, bool, None]
-            ],
-        ],
-    ):
+        return ret_client_socket
+
+    def _get_all_client_sockets_in_group(self, group: str) -> Iterable[socket.socket]:
         """
-        Sends the commmand and content to *ALL* clients connected
+        An iterable that returns all client sockets in a group
 
-        :param command: A string, representing the command to send to every client
-        :type command: str
-        :param content: A bytes-like object, containing the message/content to send
-            to each client
-        :type content: Union[bytes, dict]
+        :param group: The group to get the sockets from.
+        :type group: str
+
+        :return: An iterable of client sockets in the group.
+        :rtype: Iterable[socket.socket]
+
+        .. note::
+           If the group does not exist, an empty iterable is returned.
         """
-        if isinstance(content, dict):
-            content = json.dumps(content).encode()
 
-        content_header = make_header(command.encode() + b" " + content, self.header_len)
-        for client in self.clients:
-            client.send(content_header + command.encode() + b" " + content)
+        return _dict_tupkey_lookup(group, self.clients_rev, idx_to_match=2)
 
-    def send_group(
-        self,
-        group: Union[str, ClientInfo],
-        command: str,
-        content: Union[
-            bytes,
-            dict[
-                Union[str, int, float, bool, None], Union[str, int, float, bool, None]
-            ],
-        ],
-    ):
+    def get_group(self, group: str) -> list[dict[str, Union[str, socket.socket]]]:
         """
-        Sends data to a specific group.
-        Groups are recommended for more complicated servers or multipurpose
-        servers, as it allows clients to be divided, which allows clients to
-        be sent different data for different purposes.
+        Gets all clients from a specific group.
 
-        :param group: A string or a ClientInfo, representing the group to send data to.
-            If the group is a ClientInfo, and the client is in a group, the method will
-            send data to that group. If the client's not in a group, it will return a
-            ``TypeError``
-        :type group: Union[str, ClientInfo]
-        :param command: A string, containing the command to send
-        :type command: str
-        :param content: A bytes-like object, with the content/message
-            to send
-        :type content: Union[bytes, dict]
-        :raise TypeError: The group does not exist, or the client
-            is not in a group (``ClientInfo``)
+        .. note::
+            If you want to get them from ``clients_rev`` directly, use
+            :meth:`_get_all_client_sockets_in_group` instead.
+
+        :param group: A string, representing the group to look up
+        :type group: str
+
+        :raises GroupNotFound: Group does not exist
+
+        :return: A list of dictionaries of clients in that group, containing
+          the address, name, group, and socket
+        :rtype: list
         """
-        if isinstance(group, ClientInfo):
-            clt = group
-            group = clt.group  # Please don't confuse this
 
-            if group is None:
-                raise TypeError(f"Client {clt} does not belong to a group")
+        mod_group_clients = []  # Will be a list of dicts
 
-        # Identifies group
-        group_clients = _dict_tupkey_lookup(group, self.clients_rev, idx_to_match=2)
-        group_clients = list(group_clients)
+        for client in self._get_all_client_sockets_in_group(group):
+            client_dict = self.clients[client]
+            mod_dict = {
+                "ip": client_dict["ip"],
+                "name": client_dict["name"],
+                "group": client_dict["group"],
+                "socket": client,
+            }
+            mod_group_clients.append(mod_dict)
 
-        if len(group_clients) == 0:
-            raise TypeError(f"Group {group} does not exist")
+        if len(mod_group_clients) == 0:
+            raise GroupNotFound(f'Group "{group}" does not exist.')
 
-        if isinstance(content, dict):
-            content = json.dumps(content).encode()
+        return mod_group_clients
 
-        content_header = make_header(
-            command.encode() + b" " + content, self.header_len
-        )
-        # Send content and header to all clients in group
-        for clt_to_send in group_clients:
-            clt_to_send.send(content_header + command.encode() + b" " + content)
+    def get_all_clients(self, key: Union[Callable, str] = None) -> list[dict[str, str]]:
+        """
+        Get all clients currently connected to the server.
+        This is recommended over the class attribute ``self._clients`` or
+        ``self.clients_rev``, as it is in a dictionary-like format.
 
-    def send_client(
-        self,
-        client: Union[str, tuple[str, int], ClientInfo],
-        command: str,
-        content: Union[
-            bytes,
-            dict[
-                Union[str, int, float, bool, None], Union[str, int, float, bool, None]
-            ],
-        ],
-    ):
+        :param key: If specified, there are two outcomes: If it is a string,
+            it will search for the dictionary for the key, and output it to a list
+            (currently supports "ip", "name", "group").
+            If it is a callable, it will try to integrate the callable
+            into the output with the :meth:`filter` function.
+        :type key: Union[Callable, str], optional
+
+        :return: A list of dictionaries, with the clients
+        :rtype: list[dict, ...]
         """
-        Sends data to a specific client.
-        Different formats of the client is supported. It can be:
 
-        - An IP + Port format, written as "ip:port"
+        clients = list(self.clients.values())
 
-        - A client name, if it exists
+        if key is None:
+            return clients
 
-        - A tuple with an (IP, Port) format
+        filter_clients = []
+        if isinstance(key, str):
+            if key in ["ip", "name", "group"]:
+                for filter_client in clients:
+                    filter_clients.append(filter_client[key])
+        elif isinstance(key, Callable):
+            filter_clients = list(filter(key, clients))
 
-        - A ``ClientInfo`` instance (passed automatically to hisock functions by its underhood)
+        return filter_clients
 
-        :param client: The client to send data to. The format could be either by IP+Port,
-            or a client name
-        :type client: Union[str, tuple, ClientInfo]
-        :param command: A string, containing the command to send
-        :type command: str
-        :param content: A bytes-like object, with the content/message
-            to send
-        :type content: Union[bytes, dict]
-        :raise ValueError: Client format is wrong
-        :raise TypeError: Client does not exist
-        :raise UserWarning: Using client name, and more than one client with
-            the same name is detected
+    def get_client(
+        self, client: Union[str, tuple[str, int]]
+    ) -> dict[str, Union[str, socket.socket]]:
         """
-        if isinstance(content, dict):
-            content = json.dumps(content).encode()
-        if isinstance(client, ClientInfo):
-            client = client.ip
+        Gets the client data for a client from a name or tuple in the form of (ip, port).
 
-        content_header = make_header(command.encode() + b" " + content, self.header_len)
-        # r"((\b(0*(?:[1-9]([0-9]?){2}|255))\b\.){3}\b(0*(?:[1-9][0-9]?[0-9]?|255))\b):(\b(0*(?:[1-9]([0-9]?){4}|65355))\b)"
+        :return: The client data without the socket.
+        :rtype: dict
 
-        if isinstance(client, tuple):
-            # Formats client IP tuple, and raises Exceptions if format's wrong
-            if len(client) == 2 and isinstance(client[0], str):
-                if re.search(r"^((\d?){3}\.){3}(\d\d?\d?)$", client[0]) and isinstance(
-                    client[1], int
-                ):
-                    client = f"{client[0]}:{client[1]}"
-                else:
-                    raise ValueError(
-                        f"Client tuple format should be ('ip.ip.ip.ip', port), not "
-                        f"{client}"
-                    )
-            else:
-                raise ValueError(
-                    f"Client tuple format should be ('ip.ip.ip.ip', port), not "
-                    f"{client}"
-                )
+        :raises ValueError: Client format is wrong.
+        :raises ClientNotFound: Client does not exist.
+        :raises UserWarning: Using client name, and more than one client with
+            the same name is detected.
+        """
 
-        if re.search(r"^((\d?){3}\.){3}(\d\d?\d?):\d(\d?){4}$", client):
-            # Matching: 523.152.135.231:92344   Invalid IP handled by Python
-            # Try IP Address, should be unique
-            split_client = client.split(":")
-            reconstructed_client = []
+        return self.clients[self._get_client_from_name_or_ip_port(client)]
 
-            # Checks IP correctness
-            try:
-                reconstructed_client.append(map(int, split_client[0].split(".")))
-            except ValueError:
-                raise ValueError("IP is not numerical (only IPv4 currently supported)") from None
-            try:
-                reconstructed_client.append(int(split_client[1]))
-            except ValueError:
-                raise ValueError(
-                    "Port is not numerical (only IPv4 currently supported)"
-                ) from None
+    def get_addr(self) -> tuple[str, int]:
+        """
+        Gets the address of where the HiSock server is serving at.
 
-            for subip in reconstructed_client[0]:
-                if not 0 <= subip < 255:
-                    raise ValueError(f"{client} is not a valid IP address")
-            if not 0 < reconstructed_client[1] < 65535:
-                raise ValueError(f"{split_client[1]} is not a valid port (1-65535)")
+        :return: A tuple of the address in the form of (ip, port)
+        :rtype: tuple[str, int]
+        """
 
-            try:
-                client_sock = next(
-                    _dict_tupkey_lookup(
-                        (client.split(":")[0], reconstructed_client[1]),
-                        self.clients_rev,
-                        idx_to_match=0,
-                    )
-                )
-            except StopIteration:
-                raise TypeError(f"Client with IP {client} is not connected") from StopIteration
+        return self.addr
 
-            client_sock.send(content_header + command.encode() + b" " + content)
-        else:
-            # Try name or group
-            try:
-                mod_clients_rev = {}
-                for key, value in self.clients_rev.items():
-                    mod_key = (key[0], key[1])  # Groups shouldn't count
-                    mod_clients_rev[mod_key] = value
+    # Transmit data
 
-                client_sock = list(
-                    _dict_tupkey_lookup(client, mod_clients_rev, idx_to_match=1)
-                )
-            except StopIteration:
-                raise TypeError(f'Client with name "{client}"does not exist')
+    def send_all_clients(self, command: str, content: Sendable = None):
+        """
+        Sends the command and content to *ALL* clients connected.
 
-            if isinstance(content, dict):
-                content = json.dumps(content).encode()
+        :param command: A string, representing the command to send to every client.
+        :type command: str
+        :param content: The message / content to send
+        :type content: Sendable, optional
+        """
 
-            content_header = make_header(
-                command.encode() + b" " + content, self.header_len
-            )
+        data_to_send = (
+            b"$CMD$" + command.encode() + b"$MSG$" + self._send_type_cast(content)
+        )
+        content_header = make_header(data_to_send, self.header_len)
+        for client in self.clients:
+            client.send(content_header + data_to_send)
 
-            if len(client_sock) > 1:
-                warnings.warn(
-                    f'{len(client_sock)} clients with name "{client}" detected; sending data to '
-                    f"Client with IP {':'.join(map(str, client_sock[0].getpeername()))}"
-                )
+    def _send_all_clients_raw(self, content: Sendable = None):
+        """
+        Sends the command and content to *ALL* clients connected *without a command*.
 
-            client_sock[0].send(content_header + command.encode() + b" " + content)
+        .. note::
+           This has been deprecated for dynamic arguments.
+           Use :meth:`send_all_clients` instead.
 
-    def send_client_raw(
-        self, client: Union[str, ClientInfo], content: bytes
-    ):  # TODO: Add dict-sending support to this method
-        """
-        Sends data to a specific client, *without a command*
-        Different formats of the client is supported. It can be:
+        .. warning::
+           The serveot an unreserved
+           or reserved command.
 
-        - An IP + Port format, written as "ip:port"
+        :param content: The message / content to send
+        :type content: Sendable
+        """
 
-        - A client name, if it exists
+        content_header = make_header(content, self.header_len)
+        for client in self.clients:
+            client.send(content_header + content)
 
-        - A tuple with an (IP, Port) format
+    def send_group(
+        self, group: Union[str, ClientInfo], command: str, content: Sendable = None
+    ):
+        """
+        Sends data to a specific group.
+        Groups are recommended for more complicated servers or multipurpose
+        servers, as it allows clients to be divided, which allows clients to
+        be sent different data for different purposes.
 
-        :param client: The client to send data to. The format could be either by IP+Port,
-            a client name, or a ``ClientInfo`` instance.
-        :type client: Union[str, tuple, ClientInfo]
-        :param content: A bytes-like object, with the content/message
-            to send
+        :param group: A string or a ClientInfo, representing the group to send data to.
+            If the group is a ClientInfo, and the client is in a group, the method will
+            send data to that group.
+        :type group: Union[str, ClientInfo]
+        :param command: A string, containing the command to send
+        :type command: str
+        :param content: A bytes-like object, with the content/message to send
         :type content: Union[bytes, dict]
-        :raise ValueError: Client format is wrong
-        :raise TypeError: Client does not exist
-        :raise Warning: Using client name and more than one client with
-            the same name is detected
+
+        :raises TypeError: If the group does not exist, or the client
+            is not in a group (``ClientInfo``).
         """
-        if isinstance(client, ClientInfo):
-            client = client.ip
 
-        content_header = make_header(content, self.header_len)
-        # r"((\b(0*(?:[1-9]([0-9]?){2}|255))\b\.){3}\b(0*(?:[1-9][0-9]?[0-9]?|255))\b):(\b(0*(?:[1-9]([0-9]?){4}|65355))\b)"
+        if isinstance(group, ClientInfo):
+            client = group
+            group = client.group  # Please don't confuse this
 
-        if isinstance(client, tuple):
-            if len(client) == 2 and isinstance(client[0], str):
-                if re.search(r"(((\d?){3}\.){3}(\d?){3})", client[0]) and isinstance(
-                    client[1], int
-                ):
-                    client = f"{client[0]}:{client[1]}"
-                else:
-                    raise ValueError(
-                        f"Client tuple format should be ('ip.ip.ip.ip', port), not "
-                        f"{client}"
-                    )
-            else:
-                raise ValueError(
-                    f"Client tuple format should be ('ip.ip.ip.ip', port), not "
-                    f"{client}"
-                )
+            if group is None:
+                raise TypeError(f"Client {client} does not belong to a group")
 
-        if re.search(r"(((\d?){3}\.){3}(\d?){3}):(\d?){5}", client):
-            # Matching: 523.152.135.231:92344   Invalid IP handled by Python
-            # Try IP Address, should be unique
-            split_client = client.split(":")
-            reconstructed_client = []
-            try:
-                reconstructed_client.append(map(int, split_client[0].split(".")))
-            except ValueError:
-                raise ValueError("IP is not numerical (only IPv4 currently supported)")
-            try:
-                reconstructed_client.append(int(split_client[1]))
-            except ValueError:
-                raise ValueError(
-                    "Port is not numerical (only IPv4 currently supported)"
-                )
+        data_to_send = (
+            b"$CMD$" + command.encode() + b"$MSG$" + self._send_type_cast(content)
+        )
+        content_header = make_header(data_to_send, self.header_len)
+        for client in self._get_all_client_sockets_in_group(group):
+            client.send(content_header + data_to_send)
 
-            for subip in reconstructed_client[0]:
-                if not 0 <= subip < 255:
-                    raise ValueError(f"{client} is not a valid IP address")
-            if not 0 < reconstructed_client[1] < 65535:
-                raise ValueError(f"{split_client[1]} is not a valid port (1-65535)")
+    def send_client(self, client: Client, command: str, content: Sendable = None):
+        """
+        Sends data to a specific client.
 
-            try:
-                client_sock = next(
-                    _dict_tupkey_lookup(
-                        (client.split(":")[0], reconstructed_client[1]),
-                        self.clients_rev,
-                        idx_to_match=0,
-                    )
-                )
-            except StopIteration:
-                raise TypeError(f"Client with IP {client} is not connected")
+        :param client: The client to send data to. The format could be either by IP+port,
+            or a client name.
+        :type client: Client
+        :param command: A string, containing the command to send.
+        :type command: str
+        :param content: The message / content to send
+        :type content: Sendable
 
-            client_sock.send(content_header + content)
-        else:
-            # Try name or group
-            try:
-                mod_clients_rev = {}
-                for key, value in self.clients_rev.items():
-                    mod_key = (key[0], key[1])  # Groups shouldn't count
-                    mod_clients_rev[mod_key] = value
+        :raises ValueError: Client format is wrong.
+        :raises ClientNotFound: Client does not exist.
+        :raises UserWarning: Using client name, and more than one client with
+            the same name is detected.
+        """
 
-                client_sock = list(
-                    _dict_tupkey_lookup(client, mod_clients_rev, idx_to_match=1)
-                )
-            except StopIteration:
-                raise TypeError(f'Client with name "{client}"does not exist')
+        if isinstance(client, ClientInfo):
+            client = client.ip
+
+        data_to_send = (
+            b"$CMD$" + command.encode() + b"$MSG$" + self._send_type_cast(content)
+        )
+        content_header = make_header(data_to_send, self.header_len)
+        self._get_client_from_name_or_ip_port(client).send(
+            content_header + data_to_send
+        )
+
+    def _send_client_raw(self, client: Client, content: Sendable = None):
+        """
+        Sends data to a specific client, *without a command*
+        Different formats of the client is supported. It can be:
 
-            if isinstance(content, dict):
-                content = json.dumps(content).encode()
+        :param client: The client to send data to. The format could be either by IP+port,
+            or a client name.
+        :type client: Client
+        :param content: The message / content to send.
+        :type content: Sendable
 
-            content_header = make_header(content, self.header_len)
+        :raises ValueError: Client format is wrong.
+        :raises TypeError: Client does not exist.
+        :raises UserWarning: Using client name, and more than one client with
+            the same name is detected.
+        """
 
-            if len(client_sock) > 1:
-                # More than one client with same name
-                warnings.warn(
-                    f'{len(client_sock)} clients with name "{client}" detected; sending data to '
-                    f"Client with IP {':'.join(map(str, client_sock[0].getpeername()))}"
-                )
+        if isinstance(client, ClientInfo):
+            client = client.ip
 
-            # Sends to client
-            client_sock[0].send(content_header + content)
+        data_to_send = self._send_type_cast(content)
+        content_header = make_header(data_to_send, self.header_len)
+        self._get_client_from_name_or_ip_port(client).send(
+            content_header + data_to_send
+        )
 
-    def send_group_raw(
-        self, group: str, content: bytes
-    ):  # TODO: Add dict-sending support to this method
+    def _send_group_raw(self, group: str, content: Sendable = None):
         """
         Sends data to a specific group, without commands.
         Groups are recommended for more complicated servers or multipurpose
         servers, as it allows clients to be divided, which allows clients to
         be sent different data for different purposes.
-
-        Non-command-attached content is recommended to be used alongside with
-        :meth:`HiSockClient.recv_raw`
-
-        :param group: A string, representing the group to send data to
-        :type group: str
+        :param group: A string or a ClientInfo, representing the group to send data to.
+            If the group is a ClientInfo, and the client is in a group, the method will
+            send data to that group. If the client's not in a group, it will return a
+            ``TypeError``
+        :type group: Union[str, ClientInfo]
         :param content: A bytes-like object, with the content/message
             to send
-        :type content: bytes
-        :raise TypeError: The group does not exist
+        :type content: Union[bytes, dict]
+        :raise TypeError: The group does not exist, or the client
+            is not in a group (``ClientInfo``)
         """
-        # Identifies group
-        group_clients = _dict_tupkey_lookup(group, self.clients_rev, idx_to_match=2)
-        group_clients = list(group_clients)
 
-        if len(group_clients) == 0:
-            raise TypeError(f"Group {group} does not exist")
-
-        if isinstance(content, dict):
-            content = json.dumps(content).encode()
+        if isinstance(group, ClientInfo):
+            client = group
+            group = client.group  # Please don't confuse this
 
-        content_header = make_header(content, self.header_len)
-        # Send content and header to all clients in group
-        for clt_to_send in group_clients:
-            clt_to_send.send(content_header + content)
+            if group is None:
+                raise TypeError(f"Client {client} does not belong to a group")
 
-    def get_cache(
-        self,
-        idx: Union[int, slice, None] = None,
-    ) -> list[MessageCacheMember]:
-        """
-        Gets the message cache.
+        data_to_send = self._send_type_cast(content)
+        content_header = make_header(data_to_send, self.header_len)
+        for client in self._get_all_client_sockets_in_group(group):
+            client.send(content_header + data_to_send)
 
-        :param idx: An integer or ``slice``, specifying what specific message caches to return.
+    # Disconnect
 
-            Defaults to None (Retrieves the entire cache)
-        :type idx: Union[int, slice], optional
+    def disconnect_client(
+        self, client: Client, force: bool = False, call_func: bool = False
+    ):
+        """
+        Disconnects a specific client.
 
-        :return: A list of dictionaries, representing the cache
-        :rtype: list[dict]
+        :param client: The client to send data to. The format could be either by IP+port,
+            a client name, or a ``ClientInfo`` instance.
+        :type client: Client
+        :param force: A boolean, specifying whether to force a disconnection
+            or not. Defaults to False.
+        :type force: bool, optional
+        :param call_func: A boolean, specifying whether to call the ``leave`` reserved
+            function when client is disconnected. Defaults to False.
+
+        :raises ValueError: If the client format is wrong.
+        :raises ClientNotFound: If the client does not exist.
+        :raises UserWarning: Using client name, and more than one client with
+            the same name is detected.
         """
-        if idx is None:
-            return self.cache
 
-        return self.cache[idx]
+        if isinstance(client, ClientInfo):
+            client = client.ip
 
-    def run(self):
-        """
-        Runs the server. This method handles the sending and receiving of data,
-        so it should be run once every iteration of a while loop, as to not
-        lose valuable information. This is also called underhood in :meth:`start`.
-        """
-        self.called_run = True
-
-        if not self.closed:
-            # gets all sockets from select.select
-            # write_sock and exception_sock are the second and third return,
-            # but I've yet figured out how to use them
-            read_sock, _, _ = select.select(
-                self._sockets_list, [], self._sockets_list
+        client_socket = self._get_client_from_name_or_ip_port(client)
+        client_data = self.clients[client_socket]
+
+        if not force:
+            try:
+                self._send_client_raw(client, "$DISCONN$")
+            except BrokenPipeError:
+                # Client is already gone
+                pass
+        self._client_disconnection(client_socket)
+
+        if call_func and "leave" in self.funcs:
+            self._call_function_reserved(
+                "leave",
+                self._type_cast_client_data(command="leave", client_data=client_data),
             )
 
-            for notified_sock in read_sock:
-                # loops through all sockets
-                if notified_sock == self.sock:  # Got new connection
-                    connection, address = self.sock.accept()
+    def disconnect_all_clients(self, force=False):
+        """Disconnect all clients."""
 
-                    # Handle client hello
-                    client = receive_message(connection, self.header_len)
+        if not force:
+            self._send_all_clients_raw(b"$DISCONN$")
+            return
 
-                    client_hello = _removeprefix(client["data"].decode(), "$CLTHELLO$ ")
-                    client_hello = json.loads(client_hello)
+        for conn in self._sockets_list:
+            conn.close()
 
-                    # Establishes socket lists and dicts
-                    self._sockets_list.append(connection)
+        self._sockets_list.clear()
+        self._sockets_list.append(self.socket)  # Server socket must be first
+        self.clients.clear()
+        self.clients_rev.clear()
+        self._unresponsive_clients.clear()  # BrokenPipeError with keepalive w/out clear
 
-                    clt_info = {
-                        "ip": address,
-                        "name": client_hello["name"],
-                        "group": client_hello["group"],
-                    }
+    # Run
 
-                    self.clients[connection] = clt_info
-                    self.clients_rev[
-                        (address, client_hello["name"], client_hello["group"])
-                    ] = connection
+    def _run(self):
+        """
+        Handles new messages and sends them to the appropriate functions. This method
+        should be called in a while loop in a thread. If this function isn't in its
+        own thread, then :meth:`recv` won't work.
 
-                    if "join" in self.funcs:
-                        # Reserved function - Join
-                        self._call_function("join", ClientInfo(**clt_info))
+        .. warning::
+           Don't call this method on its own; instead use :meth:`start`.
+        """
 
-                    # Send reserved functions over to existing clients
-                    clt_cnt_header = make_header(
-                        f"$CLTCONN$ {json.dumps(clt_info)}", self.header_len
-                    )
-                    clt_to_send = [clt for clt in self.clients if clt != connection]
+        if self.closed:
+            return
 
-                    for sock_client in clt_to_send:
-                        sock_client.send(
-                            clt_cnt_header
-                            + f"$CLTCONN$ {json.dumps(clt_info)}".encode()
-                        )
+        read_socket, write_socket, exception_socket = select.select(
+            self._sockets_list, [], self._sockets_list
+        )
 
-                else:
-                    # "header" - The header of the msg, mostly not needed
-                    # "data" - The actual data/content of the msg
-                    if notified_sock.fileno() == -1:
-                        self._force_remove(notified_sock)
+        client_socket: socket.socket
+        for client_socket in read_socket:
+            try:
+                ### Reserved commands ###
+
+                # Handle bad client
+                if client_socket.fileno() == -1:
+                    # Client already disconnected
+                    # This can happen in the case of a keepalive that wasn't responded to
+                    # or the client already disconnected and it was already handled
+                    if client_socket not in self.clients:
                         continue
 
-                    message = receive_message(notified_sock, self.header_len)
-
-                    if not message or message["data"] == b"$USRCLOSE$":
-                        # Most likely client disconnect, sometimes can be client error
-                        client_disconnect = self.clients[notified_sock]["ip"]
-                        more_client_info = self.clients[notified_sock]
-
-                        # Remove socket from lists and dictionaries
-                        self._force_remove(notified_sock)
-
-                        if "leave" in self.funcs:
-                            # Reserved function - Leave
-                            self._call_function(
-                                "leave",
-                                ClientInfo(
-                                    client_disconnect,
-                                    more_client_info["name"],
-                                    more_client_info["group"],
-                                )
-                            )
-                        if notified_sock in self._unresponsive_clients:
-                            self._unresponsive_clients.remove(notified_sock)
-
-                        # Send reserved functions to existing clients
-                        clt_dcnt_header = make_header(
-                            f"$CLTDISCONN$ {json.dumps(more_client_info)}",
-                            self.header_len,
-                        )
-
-                        for clt_to_send in self.clients:
-                            clt_to_send.send(
-                                clt_dcnt_header
-                                + f"$CLTDISCONN$ {json.dumps(more_client_info)}".encode()
-                            )
-                    else:
-                        # Actual client message received
-                        clt_data = self.clients[notified_sock]
-                        usr_sent_dict = False
-
-                        if message["data"] == b"$KEEPACK$":
-                            self._unresponsive_clients.remove(notified_sock)
-                        elif message["data"].startswith(b"$USRSENTDICT$"):
-                            message["data"] = _removeprefix(
-                                message["data"], b"$USRSENTDICT$"
-                            )
-                            usr_sent_dict = True
-                        elif message["data"].startswith(b"$GETCLT$"):
-                            try:
-                                result = self.get_client(
-                                    _removeprefix(
-                                        message["data"], b"$GETCLT$ "
-                                    ).decode()
-                                )
-                                del result["socket"]
-
-                                clt = json.dumps(result)
-                            except ValueError as e:
-                                clt = '{"traceback": "' + str(e) + '"'
-                            except TypeError:
-                                clt = '{"traceback": "$NOEXIST$"}'
-
-                            clt_header = make_header(clt.encode(), self.header_len)
-
-                            print(clt)
-                            notified_sock.send(clt_header + clt.encode())
-
-                        for matching_reserve in [b"$CHNAME$", b"$CHGROUP$"]:
-                            if message["data"].startswith(matching_reserve):
-                                name_or_group = _removeprefix(
-                                    message["data"], matching_reserve + b" "
-                                ).decode()
-
-                                if name_or_group == message["data"].decode():
-                                    # Most likely request to reset name
-                                    name_or_group = None
-
-                                clt_info = self.clients[notified_sock]
-                                clt_dict = {"ip": clt_info["ip"]}
-
-                                if matching_reserve == b"$CHNAME$":
-                                    clt_dict["name"] = name_or_group
-                                    clt_dict["group"] = clt_info["group"]
-
-                                elif matching_reserve == b"$CHGROUP$":
-                                    clt_dict["name"] = clt_info["name"]
-                                    clt_dict["group"] = name_or_group
-
-                                del self.clients[notified_sock]
-                                self.clients[notified_sock] = clt_dict
-
-                                for key, value in dict(self.clients_rev).items():
-                                    if value == notified_sock:
-                                        del self.clients_rev[key]
-                                self.clients_rev[
-                                    tuple(clt_dict.values())
-                                ] = notified_sock
-
-                                if (
-                                    "name_change" in self.funcs
-                                    and matching_reserve == b"$CHNAME$"
-                                ):
-                                    old_name = clt_info["name"]
-                                    new_name = name_or_group
-
-                                    self._call_function(
-                                        "name_change", ClientInfo(**clt_dict), old_name, new_name
-                                    )
-                                elif (
-                                    "group_change" in self.funcs
-                                    and matching_reserve == b"$CHGROUP$"
-                                ):
-                                    old_group = clt_info["group"]
-                                    new_group = name_or_group
-
-                                    self._call_function(
-                                        "group_change", ClientInfo(**clt_dict), old_group, new_group
-                                    )
-
-                        if "message" in self.funcs:
-                            # Reserved function - message
-                            inner_clt_data = self.clients[notified_sock]
-                            parse_content = message["data"]
-
-                            ####################################################
-                            #         Type hinting -> Type casting             #
-                            ####################################################
-                            if usr_sent_dict:
-                                parse_content = json.loads(message["data"])
-                            temp_parse_content = _type_cast(
-                                self.funcs["message"]["type_hint"]["msg"],
-                                message["data"],
-                                self.funcs["message"],
-                            )
-
-                            if temp_parse_content == parse_content and usr_sent_dict:
-                                parse_content = json.loads(message["data"])
-                            elif temp_parse_content is not None:
-                                parse_content = temp_parse_content
-                            elif temp_parse_content is None:
-                                raise InvalidTypeCast(
-                                    f"{self.funcs['message']['type_hint']['msg']} is an invalid "
-                                    f"type cast!"
-                                )
-
-                            self._call_function(
-                                "message", ClientInfo(**inner_clt_data), parse_content
-                            )
-
-                        has_corresponding_function = False
-                        parse_content = None  # FINE pycharm
-                        command = None
-
-                        for matching_cmd, func in self.funcs.items():
-                            if message["data"].startswith(matching_cmd.encode()):
-                                has_corresponding_function = True
-                                command = matching_cmd
-
-                                parse_content = message["data"][len(matching_cmd) + 1 :]
-
-                                temp_parse_content = _type_cast(
-                                    func["type_hint"]["msg"],
-                                    parse_content,
-                                    func,
-                                )
-
-                                if (
-                                    temp_parse_content == parse_content
-                                    and usr_sent_dict
-                                ):
-                                    parse_content = json.loads(parse_content)
-                                elif temp_parse_content is not None:
-                                    parse_content = temp_parse_content
-                                elif temp_parse_content is None:
-                                    raise InvalidTypeCast(
-                                        f"{func['type_hint']['msg']} is an invalid "
-                                        f"type cast!"
-                                    )
-
-                                instantiated_clt_data = ClientInfo(
-                                    **clt_data
-                                )
-                                if not func["threaded"]:
-                                    func["func"](instantiated_clt_data, parse_content)
-                                else:
-                                    function_thread = threading.Thread(
-                                        target=func["func"],
-                                        args=(
-                                            instantiated_clt_data,
-                                            parse_content,
-                                        ),
-                                    )
-                                    function_thread.setDaemon(
-                                        True
-                                    )  # FORGIVE ME PEP 8 FOR I HAVE SINNED
-                                    function_thread.start()
-
-                        # Caching
-                        if self.cache_size >= 0:
-                            if has_corresponding_function:
-                                cache_content = parse_content  # Bruh pycharm it DOES exist if hcf is True
-                            else:
-                                cache_content = message["data"]
-                            self.cache.append(
-                                MessageCacheMember(
-                                    {
-                                        "header": message["header"],
-                                        "content": cache_content,
-                                        "called": has_corresponding_function,
-                                        "command": command,
-                                    }
-                                )
-                            )
-
-                            if 0 < self.cache_size < len(self.cache):
-                                self.cache.pop(0)
-
-    def start(self):
-        """
-        Starts a while loop that actually runs the server long-term. Exactly equivalent to:
+                    self.disconnect_client(
+                        self.clients[client_socket]["ip"], force=True, call_func=False
+                    )
+                    continue
 
-        .. code-block:: python
-           while not server.closed:
-               server.run()
+                # Handle new connection
+                # select.select() returns the server socket if a new connection is made
+                if client_socket == self.socket:
+                    self._new_client_connection(*self.socket.accept())
+                    continue
 
-        """
-        while not self.closed:
-            self.run()
+                ### Receiving data ###
+                data: bytes = b""
 
-    def recv_raw(self) -> bytes:
-        """
-        Waits (blocks) until a message is sent, and returns that message.
-        This is not recommended for content with commands attached;
-        it is meant to be used alongside with :func:`HiSockCerver.send_raw`
+                # {"header": bytes, "data": bytes} or False
+                self._receiving_data = True
+                raw_data = receive_message(client_socket, self.header_len)
+                self._receiving_data = False
 
-        :return: A bytes-like object, containing the content/message
-          the client first receives
-        :rtype: bytes
-        """
-        # Blocks depending on your blocking settings, until message
-        msg_len = int(self.sock.recv(self.header_len).decode())
-        message = self.sock.recv(msg_len)
+                if isinstance(raw_data, dict):
+                    data = raw_data["data"]
 
-        # Returns message
-        return message
+                try:
+                    client_data = self.clients[client_socket]
+                except KeyError:
+                    raise ClientNotFound(
+                        "Client data not found, but is not a new client."
+                    ) from KeyError
 
-    def get_group(self, group: str) -> list[dict[str, Union[str, socket.socket]]]:
-        """
-        Gets all clients from a specific group
+                ### Reserved commands ###
 
-        :param group: A string, representing the group to look up
-        :type group: str
-        :raise TypeError: Group does not exist
+                # Handle client disconnection
+                if (
+                    not raw_data  # Most likely client disconnect, could be client error
+                    or data.startswith(b"$USRCLOSE$")
+                ):
 
-        :return: A list of dictionaries of clients in that group, containing
-          the address, name, group, and socket
-        :rtype: list
-        """
-        group_clients = list(
-            _dict_tupkey_lookup_key(group, self.clients_rev, idx_to_match=2)
-        )
-        mod_group_clients = []
+                    try:
+                        self.disconnect_client(
+                            client_data["ip"], force=False, call_func=True
+                        )
+                    except BrokenPipeError:  # UNIX
+                        # Client is already gone
+                        pass
+                    except ConnectionResetError:
+                        self.disconnect_client(
+                            client_data["ip"], force=True, call_func=True
+                        )
 
-        if len(group_clients) == 0:
-            raise TypeError(f"Group {group} does not exist")
+                    continue
 
-        for clt in group_clients:
-            # Loops through group clients and append to list
-            clt_conn = self.clients_rev[clt]
-            mod_dict = {
-                "ip": clt[0],
-                "name": clt[1],
-                "group": clt[2],
-                "socket": clt_conn,
-            }
-            mod_group_clients.append(mod_dict)
+                # Change name or group
+                for matching_reserve, key in zip(
+                    (b"$CHNAME$", b"$CHGROUP$"), ("name", "group")
+                ):
 
-        return mod_group_clients
+                    if not data.startswith(matching_reserve):
+                        continue
 
-    def get_all_clients(
-        self, key: Union[Callable, str] = None
-    ) -> list[dict[str, str]]:  # TODO: Add socket output as well
-        """
-        Get all clients currently connected to the server.
-        This is recommended over the class attribute `self._clients` or
-        `self.clients_rev`, as it is in a dictionary-like format
+                    change_to = _removeprefix(data, matching_reserve).decode()
 
-        :param key: If specified, there are two outcomes: If it is a string,
-            it will search for the dictionary for the key,
-            and output it to a list (currently support "ip", "name", "group").
-            Finally, if it is a callable, it will try to integrate the callable
-            into the output (CURRENTLY NOT SUPPORTED YET)
-        :type key: Union[Callable, str], optional
-        :return: A list of dictionaries, with the clients
-        :rtype: list[dict, ...]
-        """
-        clts = []
-        for clt in self.clients_rev:
-            clt_dict = {
-                dict_key: clt[value]
-                for value, dict_key in enumerate(["ip", "name", "group"])
-            }
-            clts.append(clt_dict)
+                    # Resetting
+                    if change_to == "":
+                        change_to = client_data[key]
 
-        filter_clts = []
-        if isinstance(key, str):
-            if key in ["ip", "name", "group"]:
-                for filter_clt in clts:
-                    filter_clts.append(filter_clt[key])
-        elif isinstance(key, Callable):
-            filter_clts = list(filter(key, clts))
+                    # Change it
+                    changed_client_data = client_data.copy()
+                    changed_client_data[key] = change_to
+                    self.clients[client_socket] = changed_client_data
 
-        if filter_clts:
-            return filter_clts
-        return clts
+                    del self.clients_rev[
+                        (
+                            client_data["ip"],
+                            client_data["name"],
+                            client_data["group"],
+                        )
+                    ]
+                    self.clients_rev[
+                        (
+                            changed_client_data["ip"],
+                            changed_client_data["name"],
+                            changed_client_data["group"],
+                        )
+                    ] = client_socket
+
+                    # Call reserved function
+                    reserved_func_name = f"{key}_change"
+
+                    if reserved_func_name in self._reserved_funcs:
+                        old_value = client_data[key]
+                        new_value = changed_client_data[key]
+
+                        self._call_function(
+                            reserved_func_name,
+                            self._type_cast_client_data(
+                                command=reserved_func_name,
+                                client_data=changed_client_data,
+                            ),
+                            old_value,
+                            new_value,
+                        )
 
-    def get_client(
-        self, client: Union[str, tuple[str, int]]
-    ) -> dict[str, Union[str, socket.socket]]:
-        """
-        Gets a specific client's information, based on either:
+                    return
+
+                # Handle keepalive acknowledgement
+                if data.startswith(b"$KEEPACK$"):
+                    self._handle_keepalive(client_socket)
+                    continue
+
+                # Get client
+                elif data.startswith(b"$GETCLT$"):
+                    try:
+                        client_identifier = _removeprefix(data, b"$GETCLT$")
+
+                        # Determine if the client identifier is a name or an IP+port
+                        try:
+                            validate_ipv4(client_identifier)
+                            client_identifier = ipstr_to_tup(client_identifier)
+                        except ValueError:
+                            pass
+
+                        client = self.get_client(client_identifier)
+                    except ValueError as e:
+                        client = {"traceback": str(e)}
+                    except ClientNotFound:
+                        client = {"traceback": "$NOEXIST$"}
+
+                    self._send_client_raw(client_data["ip"], client)
+                    continue
+
+                ### Unreserved commands ###
+
+                # Handle random data with no command
+                elif not data.startswith(b"$CMD$"):
+                    if "*" in self.funcs:
+                        self._call_wildcard_function(
+                            client_data=client_data, command=None, content=data
+                        )
+                    return
 
-        1. The client name
+                has_listener = False  # For cache
 
-        2. The client IP+Port
+                # Get command and message
+                command = data.lstrip(b"$CMD$").split(b"$MSG$")[0].decode()
+                content = _removeprefix(data, f"$CMD${command}$MSG$".encode())
 
-        3. The client IP+Port, in a 2-element tuple
+                # No content? (`_removeprefix` didn't do anything)
+                if not content or content == data:
+                    content = None
 
-        :param client: A parameter, representing the specific client to look up.
-            As shown above, it can either be represented
-            as a string, or as a tuple.
-        :type client: Union[str, tuple]
-        :raise ValueError: Client argument is invalid
-        :raise TypeError: Client does not exist
+                # Call functions that are listening for this command from the `on`
+                # decorator
+                for matching_command, func in self.funcs.items():
+                    if command != matching_command:
+                        continue
 
-        :return: A dictionary of the client's info, including
-          IP+Port, Name, Group, and Socket
-        :rtype: dict
-        """
-        if isinstance(client, tuple):
-            if len(client) == 2 and isinstance(client[0], str):
-                if re.search(r"(((\d?){3}\.){3}(\d?){3})", client[0]) and isinstance(
-                    client[1], int
-                ):
-                    client = f"{client[0]}:{client[1]}"
-                else:
-                    raise ValueError(
-                        f"Client tuple format should be ('ip.ip.ip.ip', port), not "
-                        f"{client}"
-                    )
-            else:
-                raise ValueError(
-                    f"Client tuple format should be ('ip.ip.ip.ip', port), not "
-                    f"{client}"
-                )
+                    has_listener = True
 
-        if re.search(r"(((\d?){3}\.){3}(\d?){3}):(\d?){5}", client):
-            # Matching: 523.152.135.231:92344   Invalid IP handled by Python
-            # Try IP Address, should be unique
-            split_client = client.split(":")
-            reconstructed_client = []
-            try:
-                reconstructed_client.append(map(int, split_client[0].split(".")))
-            except ValueError:
-                raise ValueError("IP is not numerical (only IPv4 currently supported)") from None
-            try:
-                reconstructed_client.append(int(split_client[1]))
-            except ValueError:
-                raise ValueError(
-                    "Port is not numerical (only IPv4 currently supported)"
-                ) from None
+                    # Call function with dynamic args
+                    arguments = ()
+                    if len(func["type_hint"]) != 0:
+                        type_casted_client_data = self._type_cast_client_data(
+                            command=matching_command, client_data=client_data
+                        )
+                    # client_data
+                    if len(func["type_hint"]) == 1:
+                        arguments = (type_casted_client_data,)
+                    # client_data, message
+                    elif len(func["type_hint"]) >= 2:
+                        arguments = (
+                            type_casted_client_data,
+                            _type_cast(
+                                type_cast=func["type_hint"]["message"],
+                                content_to_type_cast=content,
+                                func_name=func["name"],
+                            ),
+                        )
+                    self._call_function(matching_command, *arguments)
+                    break
 
-            for subip in reconstructed_client[0]:
-                if not 0 <= subip < 255:
-                    raise ValueError(f"{client} is not a valid IP address")
-            if not 0 < reconstructed_client[1] < 65535:
-                raise ValueError(f"{split_client[1]} is not a valid port (1-65535)")
+                else:
+                    has_listener = self._handle_recv_commands(command, content)
 
-            try:
-                client_tup = (client.split(":")[0], reconstructed_client[1])
-                client_sock = next(
-                    _dict_tupkey_lookup(client_tup, self.clients_rev, idx_to_match=0)
-                )
-                client_info = next(
-                    _dict_tupkey_lookup_key(
-                        client_tup, self.clients_rev, idx_to_match=0
+                # No listener found
+                if not has_listener and "*" in self.funcs:
+                    # No recv and no catchall. A command and some data.
+                    self._call_wildcard_function(
+                        client_data=client_data, command=command, content=content
                     )
-                )
-                client_dict = {
-                    "ip": client_info[0],
-                    "name": client_info[1],
-                    "group": client_info[2],
-                    "socket": client_sock,
-                }
-
-                return client_dict
-            except StopIteration:
-                raise TypeError(f"Client with IP {client} is not connected") from StopIteration
-        else:
-            mod_clients_rev = {}
-            for key, value in self.clients_rev.items():
-                mod_key = (key[0], key[1])  # Groups shouldn't count
-                mod_clients_rev[mod_key] = value
 
-            client_sock = list(
-                _dict_tupkey_lookup(client, mod_clients_rev, idx_to_match=1)
-            )
+                # Caching
+                self._cache(has_listener, command, content, data, raw_data["header"])
+
+                # Call `message` function
+                if "message" in self.funcs:
+                    self._call_function_reserved(
+                        "message",
+                        self._type_cast_client_data(
+                            command="message", client_data=client_data
+                        ),
+                        _type_cast(
+                            type_cast=self.funcs["message"]["type_hint"]["command"],
+                            content_to_type_cast=command,
+                            func_name="<message call in run command>",
+                        ),
+                        _type_cast(
+                            type_cast=self.funcs["message"]["type_hint"]["message"],
+                            content_to_type_cast=content,
+                            func_name="<message call in run message>",
+                        ),
+                    )
+            except (BrokenPipeError, ConnectionResetError):
+                if client_socket in self.clients:
+                    # Does it need to be forced?? Investigate further
+                    self.disconnect_client(
+                        self.clients[client_socket]["ip"], force=True
+                    )
+                print("[DEBUG] 10054 exception, we're investigating")
 
-            if len(client_sock) == 0:
-                raise TypeError(f'Client with name "{client}"does not exist')
+    # Stop
 
-            if len(client_sock) > 1:
-                warnings.warn(
-                    f'{len(client_sock)} clients with name "{client}" detected; getting info from '
-                    f"Client with IP {':'.join(map(str, client_sock[0].getpeername()))}"
-                )
+    def close(self):
+        """
+        Closes the server; ALL clients will be disconnected, then the
+        server socket will be closed.
 
-            client_info = next(
-                _dict_tupkey_lookup_key(client, self.clients_rev, idx_to_match=1)
-            )
+        Running `server.run()` won't do anything now.
+        """
 
-            client_dict = {
-                "ip": client_info[0],
-                "name": client_info[1],
-                "group": client_info[2],
-                "socket": client_sock[0],
-            }
+        self.closed = True
+        self._keepalive_event.set()
+        self.disconnect_all_clients()
+        self.socket.shutdown(socket.SHUT_RDWR)
+        self.socket.close()
 
-            return client_dict
+    # Main loop
 
-    def get_addr(self) -> tuple[str, int]:
+    def start(self, callback: Callable = None, error_handler: Callable = None):
         """
-        Gets the address of where the hisock server is serving
-        at.
-
-        :return: A tuple, with the format (str IP, int port)
+        Start the main loop for the server.
+
+        :param callback: A function that will be called every time the
+            client receives and handles a message.
+        :type callback: Callable, optional
+        :param error_handler: A function that will be called every time the
+            client encounters an error.
+        :type error_handler: Callable, optional
         """
-        return self.addr
-
 
-class _BaseThreadServer(HiSockServer):
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-    def _run(self):
-        HiSockServer.run(self)
+        try:
+            while not self.closed:
+                self._run()
+                if isinstance(callback, Callable):
+                    callback()
+        except Exception as e:
+            if isinstance(error_handler, Callable):
+                error_handler(e)
+            else:
+                raise e
 
 
-class ThreadedHiSockServer(_BaseThreadServer):
-    """
-    A downside of :class:`HiSockServer` is that you need to constantly
-    :meth:`run` it in a while loop, which may block the program. Fortunately,
-    in Python, you can use threads to do two different things at once. Using
-    :class:`ThreadedHiSockServer`, you would be able to run another
-    blocking program, without ever fearing about blocking and all that stuff.
-
-    .. note::
-       In some cases though, :class:`HiSockServer` offers more control than
-       :class:`ThreadedHiSockServer`, so be careful about when to use
-       :class:`ThreadedHiSockServer` over :class:`HiSockServer`
+class ThreadedHiSockServer(HiSockServer):
     """
+    :class:`HiSockClient`, but running in its own thread as to not block the
+    main loop. Please note that while this is running in its own thread, the
+    event handlers will still be running in the main thread. To avoid this,
+    use the ``threaded=True`` argument for the ``on`` decorator.
 
-    def __init__(self, addr, blocking=True, max_connections=0, header_len=16):
-        super().__init__(addr, blocking, max_connections, header_len)
-        self._thread = threading.Thread(target=self.run)
+    For documentation purposes, see :class:`HiSockClient`.
+    """
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._thread: threading.Thread = None
         self._stop_event = threading.Event()
 
-    def stop_server(self):
-        """Stops the server"""
-        self._stop_event.set()
-        self.closed = True
-        self.sock.close()
-
-    def run(self):
+    def close(self):
+        """
+        Closes the server. Blocks the thread until the server is closed.
+        For documentation, see :meth:`HiSockServer.close`.
         """
-        The main while loop to run the thread
 
-        Refer to :class:`HiSockServer` for more details
+        super().close()
+        self._stop_event.set()
+        self._thread.join()
 
-        .. warning::
-           This method is **NOT** recommended to be used in an actual
-           production enviroment. This is used internally for the thread, and should
-           not be interacted with the user
+    def start(self, callback: Callable = None, error_handler: Callable = None):
+        """
+        Starts the main server loop.
+        For documentation, see :meth:`HiSockServer.start`.
         """
-        while not (self._stop_event.is_set() or self.closed):
-            try:
-                self._run()
-            except (OSError, ValueError):
-                break
 
-    def start_server(self):
-        """Starts the main server loop"""
+        self._thread = threading.Thread(
+            target=self._start, args=(callback, error_handler)
+        )
         self._thread.start()
 
-    def join(self):
-        """Waits for the thread to be killed"""
-        self._thread.join()
+    def _start(self, callback: Callable = None, error_handler: Callable = None):
+        """Start the main loop for the threaded server."""
 
+        def updated_callback():
+            if self._stop_event.is_set() and not self.closed:
+                self.close()
 
-def start_server(addr, blocking=True, max_connections=0, header_len=16):
+            # Original callback
+            if isinstance(callback, Callable):
+                callback()
+
+        super().start(callback=updated_callback, error_handler=error_handler)
+
+
+def start_server(addr, max_connections=0, header_len=16):
     """
-    Creates a :class:`HiSockServer` instance. See :class:`HiSockServer` for more details
+    Creates a :class:`HiSockServer` instance. See :class:`HiSockServer` for
+    more details and documentation.
 
-    :return: A :class:`HiSockServer` instance
+    :return: A :class:`HiSockServer` instance.
     """
-    return HiSockServer(addr, blocking, max_connections, header_len)
 
+    return HiSockServer(
+        addr=addr, max_connections=max_connections, header_len=header_len
+    )
 
-def start_threaded_server(addr, blocking=True, max_connections=0, header_len=16):
+
+def start_threaded_server(*args, **kwargs):
     """
     Creates a :class:`ThreadedHiSockServer` instance. See :class:`ThreadedHiSockServer`
-    for more details
+    for more details. For documentation, see :func:`start_server`.
 
     :return: A :class:`ThreadedHiSockServer` instance
     """
-    return ThreadedHiSockServer(addr, blocking, max_connections, header_len)
+
+    return ThreadedHiSockServer(*args, **kwargs)
 
 
 if __name__ == "__main__":
-    print("Starting server...")
-    # s = HiSockServer(('192.168.1.131', 33333))
-    s = start_server(("192.168.1.131", 33333))
-
-    @s.on("join")
-    def test_sussus(yum_data):
-        print("Whomst join, ahh it is", yum_data.name)
-        s.send_all_clients("Joe", b"Bidome")
-        s.send_client(f"{yum_data.ip[0]}:{yum_data.ip[1]}", "Bruh", b"E")
-        s.send_client(yum_data.ip, "e", b"E")
-
-        s.send_group("Amogus", "Test", b"TTT")
-        s.send_client(yum_data, "dicttest", {"Does this": "dict also work?"})
-
-    @s.on("leave")
-    def bruh(yum_data):
-        print("Hmmm whomst leaved, ah it is", yum_data["name"])
-
-    @s.on("name_change")
-    def smth(clt_info, old_name, new_name):
-        print(f"Bruh, {old_name} renamed to {new_name}!")
-        s.send_client(clt_info.ip, "shrek", b"")
-        s.send_client(clt_info.ip, "john", b"")
-        # s.disconnect_all_clients()
-
-    @s.on("lol")
-    def lolol(clt_info, dict_stuff):
+    print("Testing server!")
+    server = start_server(("127.0.0.1", int(input("Port: "))))
+
+    @server.on("join")
+    def on_join(client_data):
         print(
-            f"Cool, {clt_info.ip} sent out: {dict_stuff}. What's cool is that I am {dict_stuff['I am']}"
+            f"{client_data.name} has joined! "
+            f'Their IP is {":".join(map(str, client_data.ip))}. '
+            f'Their group is {client_data["group"]}.'
         )
 
-    # @s.on("message")
-    # def why(client_data, message: str):
-    #     print("Message reserved function aaa")
-    #     print("Client data:", client_data)
-    #     print("Message:", message)
+    @server.on("leave")
+    def on_leave(client_data):
+        print(f"{client_data.name} has left!")
+        server.send_all_clients(
+            "client_disconnect", {"name": client_data.name, "reason": "they left"}
+        )
+
+    @server.on("message")
+    def on_message(client_data, command: str, message: str):
+        print(
+            f"[MESSAGE CATCH-ALL] {client_data.name} sent a command, {command} "
+            f'with the message "{message}".'
+        )
+
+    @server.on("name_change")
+    def on_name_change(_, old_name: str, new_name: str):  # Client data isn't used
+        print(f"{old_name} changed their name to {new_name}.")
+
+    @server.on("group_change")
+    def on_group_change(client_data, old_group: str, new_group: str):
+        print(f"{client_data.name} changed their group to {new_group}.")
+        # Alert clients of change
+        server.send_group(
+            old_group,
+            "message",
+            f"{client_data.name} has left to move to {new_group}.",
+        )
+        server.send_group(
+            new_group,
+            "message",
+            f"{client_data.name} has joined from {old_group}.",
+        )
+
+    @server.on("ping")
+    def on_ping(client_data):
+        print(f"{client_data.name} pinged!")
+        server.send_client(client_data.ip, "pong")
+
+    @server.on("get_all_clients")
+    def on_all_clients(client_data):
+        print(f"{client_data.name} asked for all clients!")
+        server.send_client(client_data.ip, "all_clients", server.get_all_clients())
+
+    @server.on("broadcast_message")
+    def on_broadcast_message(client_data, message: str):
+        print(f'{client_data.name} said "{message}"!')
+        server.send_all_clients("message", message)
+
+    @server.on("broadcast_message_to_group")
+    def on_broadcast_message_to_group(client_data, message: str):
+        print(
+            f'{client_data.name} said "{message}" to their group, {client_data.group}!'
+        )
+        server.send_group(client_data, "message", message)
+
+    @server.on("set_timer", threaded=True)
+    def on_set_timer(client_data, seconds: int):
+        print(f"{client_data.name} set a timer for {seconds} seconds!")
+        __import__("time").sleep(seconds)
+        print(f"{client_data.name}'s timer is done!")
+        server.send_client(client_data.ip, "timer_done")
+
+    @server.on("commit_genocide")
+    def on_commit_genocide():
+        print("It's time to genocide the connected clients.")
+        server.send_all_clients("genocide")
+
+    @server.on("*")
+    def on_wildcard(client_data, command: str, data: str):
+        print(
+            f"There was some unhandled data from {client_data.name}. "
+            f"{command=}, {data=}"
+        )
 
-    @s.on("Sussus")
-    def a(_, msg):  # _ actually is clt_data
-        s.send_all_clients("pog", msg)
+        server.send_client(client_data, "uncaught_command", data.replace("a", "ඞ"))
 
-    while not s.closed:
-        s.run()
+    server.start()
diff --git a/hisock/utils.py b/hisock/utils.py
index 11c6d1f..57b4dd9 100644
--- a/hisock/utils.py
+++ b/hisock/utils.py
@@ -15,19 +15,15 @@
 
 import json
 import pathlib
-import re
 import socket
-from typing import Union, Any
-
-
-# __all__ = [
-#     'make_header', 'receive_message',
-#     'get_local_ip', 'ipstr_to_tup',
-#     'iptup_to_str'
-# ]
-
-
-# Some custom exceptions
+import builtins
+import sys
+import ast
+from re import search
+from typing import Union, Any, Optional, Type
+from ipaddress import IPv4Address
+
+# Custom exceptions
 class ClientException(Exception):
     pass
 
@@ -52,11 +48,28 @@ class ClientDisconnected(Exception):
     pass
 
 
+class FunctionNotFoundException(Exception):
+    pass
+
+
+class ClientNotFound(Exception):
+    pass
+
+
+class GroupNotFound(Exception):
+    pass
+
+
 # Custom warnings
-class NoHeaderWarning(Warning):
+class NoHeaderWarning(UserWarning):
+    pass
+
+
+class FunctionNotFoundWarning(UserWarning):
     pass
 
 
+# Custom classes
 class _Sentinel:
     pass
 
@@ -64,8 +77,7 @@ class _Sentinel:
 class MessageCacheMember:
     _available_attrs = ["header", "content", "called", "command"]
 
-    def __init__(self, message_dict):
-        # I mean... that's it
+    def __init__(self, message_dict: dict):
         self.header = message_dict.get("header", _Sentinel)
         self.content = message_dict.get("content", _Sentinel)
         self.called = message_dict.get("called", _Sentinel)
@@ -78,16 +90,20 @@ def __init__(self, message_dict):
     def __str__(self):
         return f"<MessageCacheMember: {self.content}>"
 
+    def __repr__(self):
+        return self.__str__()
+
 
 class ClientInfo:
     def __init__(self, ip, name, group):
-        self.client_dict = {"ip": ip, "name": name, "group": group}
-
-        self.ip = ip
-        self.name = name
-        self.group = group
+        self.ip: tuple[str, int] = tuple(
+            ip
+        )  # _type_cast converts tuple to list to be JSON-serializable
+        self.name: Union[str, None] = name
+        self.group: Union[str, None] = group
 
-        self.ip_as_str = f"{self.ip[0]}:{self.ip[1]}"
+        self.client_dict: dict = {"ip": self.ip, "name": self.name, "group": self.group}
+        self.ip_as_str: str = f"{self.ip[0]}:{self.ip[1]}"
 
     def __getitem__(self, item):
         return self.client_dict[item]
@@ -95,23 +111,51 @@ def __getitem__(self, item):
     def __str__(self):
         return f"<ClientInfo: IP: {self.ip_as_str}, Name: {self.name}, Group: {self.group}>"
 
+    def __eq__(self, other: ClientInfo):
+        return self.client_dict == other.client_dict
+
 
 class File:
-    def __init__(self, file: Union[str, pathlib.Path]):
-        if isinstance(file, str):
-            pass
+    def __init__(self, file_path: Union[str, pathlib.Path]):
+        # TODO: implement this!
+        self.file_path = file_path
+
+
+# Custom type hints
+JSONSerializableValue = Union[str, int, float, bool, None, dict, list]
+Sendable = Union[
+    bytes,
+    str,
+    int,
+    float,
+    None,
+    ClientInfo,
+    list[Union[str, int, float, bool, None, dict, list]],
+    dict[
+        str,
+        Union[str, int, float, bool, None, dict, list],
+    ],
+]
+
+SendableTypes = Type[Sendable]
+
+Client = Union[
+    str,  # Name
+    tuple[str, int],  # Port
+    ClientInfo,  # Returned by function, etc
+]
 
 
 def make_header(
-    header_msg: Union[str, bytes], header_len: int, encode=True
+    header_message: Union[str, bytes], header_len: int, encode=True
 ) -> Union[str, bytes]:
     """
-    Makes a header of ``header_msg``, with a maximum
+    Makes a header of ``header_message``, with a maximum
     header length of ``header_len``
 
-    :param header_msg: A string OR bytes-like object, representing
+    :param header_message: A string OR bytes-like object, representing
         the data to make a header from
-    :type header_msg: Union[str, bytes]
+    :type header_message: Union[str, bytes]
     :param header_len: An integer, specifying
         the actual header length (will be padded)
     :type header_len: int
@@ -120,8 +164,9 @@ def make_header(
         bytes
     :rtype: Union[str, bytes]
     """
-    len_msg = len(header_msg)
-    constructed_header = f"{len_msg}{' ' * (header_len - len(str(len_msg)))}"
+
+    message_len = len(header_message)
+    constructed_header = f"{message_len}{' ' * (header_len - len(str(message_len)))}"
     if encode:
         return constructed_header.encode()
     return constructed_header
@@ -142,20 +187,19 @@ def receive_message(
     :return: A dictionary, with two key-value pairs;
         The first key-value pair refers to the header,
         while the second one refers to the actual data
-    :rtype: dict["header": bytes, "data": bytes]
+    :rtype: Union[dict["header": bytes, "data": bytes], False
     """
-    try:
-        header_msg = connection.recv(header_len)
-
-        if header_msg:
-            msg_len = int(header_msg)
-            data = connection.recv(msg_len)
 
-            return {"header": header_msg, "data": data}
-        return False
+    try:
+        header_message = connection.recv(header_len)
+        if header_message:
+            message_len = int(header_message)
+            data = connection.recv(message_len)
+            return {"header": header_message, "data": data}
     except ConnectionResetError:
         # This is most likely where clients will disconnect
         pass
+    return False
 
 
 def _removeprefix(
@@ -163,9 +207,9 @@ def _removeprefix(
     prefix: Union[str, bytes],
 ) -> Union[str, bytes]:
     """A backwards-compatible alternative of str.removeprefix"""
+
     if string.startswith(prefix):
         return string[len(prefix) :]
-
     return string[:]
 
 
@@ -176,13 +220,13 @@ def _dict_tupkey_lookup(
     Returns the value of the dict looked up,
     given a key that is part of a key-tuple
     """
+
     for key, value in _dict.items():
         if idx_to_match is None:
             if multikey in key:
                 yield value
-        elif isinstance(idx_to_match, int):
-            if multikey == key[idx_to_match]:
-                yield value
+        elif isinstance(idx_to_match, int) and multikey == key[idx_to_match]:
+            yield value
 
 
 def _dict_tupkey_lookup_key(
@@ -192,117 +236,211 @@ def _dict_tupkey_lookup_key(
     Returns the key of the dict looked up,
     given a key that is part of a key-tuple
     """
+
     for key in _dict.keys():
         if idx_to_match is None:
             if multikey in key:
                 yield key
-        elif isinstance(idx_to_match, int):
-            if multikey == key[idx_to_match]:
-                yield key
+        elif isinstance(idx_to_match, int) and multikey == key[idx_to_match]:
+            yield key
 
 
-def _type_cast(type_cast: Any, content_to_typecast: bytes, func_dict: dict) -> Any:
+def _str_type_to_type_annotations_dict(annotations_dict: dict):
     """
-    Basis for type casting on the server
-    If testing, replace `func_dict` with a dummy one
-    Currently NOT guarenteed to return, please remember to change this API
+    When getting the annotations, they return as a string representation of the type.
+    However, we need the actual type.
+    This function will take the annotations dict with the string type and turn it to
+    the type.
+
+    .. note::
+        If the type hint is the type already (no conversion needed), this won't
+        break anything.
+
+    :param annotations_dict: The annotations dict to convert.
+    :type annotations_dict: dict
+    :return: The converted dict.
+    :rtype: dict
     """
-    if type_cast == bytes:
-        return content_to_typecast
-    if type_cast == str:
-        try:
-            typecasted_content = content_to_typecast.decode()
-            return typecasted_content  # Remember to change this, but I"m lazy rn
-        except UnicodeDecodeError as e:
-            raise TypeError(
-                f"Type casting from bytes to string failed for function "
-                f"\"{func_dict['name']}\"\n{str(e)}"
-            ) from UnicodeDecodeError
-    elif type_cast == int:
-        try:
-            typecasted_content = int(content_to_typecast)
-            return typecasted_content  # Remember to change this, but I"m lazy rn
-        except ValueError as e:
-            raise TypeError(
-                f"Type casting from bytes to int failed for function "
-                f"\"{func_dict['name']}\":\n           {e}"
-            ) from ValueError
-    elif type_cast == float:
+
+    fixed_annotations = {}
+    for argument, annotation in annotations_dict.items():
+        # This won't affect anything if the type is already the type
+        # Also, `isinstance(str, str)` == False
+        if not isinstance(annotation, str):
+            fixed_annotations[argument] = annotation
+            continue
         try:
-            typecasted_content = float(content_to_typecast)
-            return typecasted_content  # Remember to change this, but I"m lazy rn
-        except ValueError as e:
-            raise TypeError(
-                f"Type casting from bytes to float failed for function "
-                f"\"{func_dict['name']}\":\n           {e}"
-            ) from ValueError
-    elif type_cast is None:
-        return content_to_typecast
-    for _type in [list, dict]:
-        if type_cast == _type:
-            try:
-                typecasted_content = json.loads(content_to_typecast)
-                return typecasted_content
-            except UnicodeDecodeError:
-                raise TypeError(
-                    f"Cannot decode message data during "
-                    f"bytes->{_type.__name__} type cast"
-                    "(current implementation requires string to "
-                    "type cast, not bytes)"
-                ) from UnicodeDecodeError
-            except ValueError:
-                raise TypeError(
-                    f"Type casting from bytes to {_type.__name__} "
-                    f"failed for function \"{func_dict['name']}\""
-                    f":\n           Message is not a {_type.__name__}"
-                ) from ValueError
-            except Exception as e:
-                raise TypeError(
-                    f"Type casting from bytes to {_type.__name__} "
-                    f"failed for function \"{func_dict['name']}\""
-                    f":\n           {e}"
-                ) from type(e)
-
-
-def _parse_client_arg(client: Union[str, tuple]):
-    if isinstance(client, tuple):
-        # Formats client IP tuple, and raises Exceptions if format's wrong
-        if len(client) == 2 and isinstance(client[0], str):
-            if re.search(r"^((\d?){3}\.){3}(\d\d?\d?)$", client[0]) and isinstance(
-                client[1], int
-            ):
-                client = f"{client[0]}:{client[1]}"
+            fixed_annotations[argument] = getattr(builtins, annotation)
+        except AttributeError:
+            fixed_annotations[argument] = getattr(sys.modules[__name__], annotation)
+    return fixed_annotations
+
+
+def _type_cast(
+    type_cast: SendableTypes, content_to_type_cast: Sendable, func_name: str
+) -> Sendable:
+    """
+    Type casts data to be sent.
+
+    :param type_cast: The type to type cast to.
+    :type type_cast: Sendable
+    :param content_to_type_cast: The content to type cast. If it is not
+        bytes, this function will attempt to convert it to bytes.
+    :type content_to_type_cast: Sendable
+    :param func_name: The name of the function that is calling this function.
+        Used for exception messages.
+    :type func_name: str
+    :return: The type casted content (will be the same type as :param:`type_cast`).
+    :rtype: Sendable
+
+    :raises InvalidTypeCast: If the type cast is invalid.
+    """
+
+    content_to_type_cast_type = type(content_to_type_cast)
+
+    if type_cast is None or type_cast is content_to_type_cast_type:
+        return content_to_type_cast
+
+    try:
+        ### Special type casting ###
+
+        # Handle client info
+        if type_cast is ClientInfo:
+            return ClientInfo(**content_to_type_cast)
+
+        # Handle boolean
+        if content_to_type_cast_type == bool:
+            return type_cast(content_to_type_cast)
+
+        # Handle no data
+        if not content_to_type_cast:
+            return type_cast()
+
+        ### Regular type casting ###
+
+        # Convert content_to_type_cast to bytes
+        if content_to_type_cast_type != bytes:
+            if content_to_type_cast_type in (str, int, float):
+                content_to_type_cast = str(content_to_type_cast).encode()
+            elif content_to_type_cast_type in (list, dict):
+                content_to_type_cast = json.dumps(content_to_type_cast).encode()
             else:
-                raise ValueError(
-                    f"Client tuple format should be ('ip.ip.ip.ip', port), not "
-                    f"{client}"
+                raise TypeError(
+                    f"Cannot type cast {content_to_type_cast_type} to bytes"
                 )
-        else:
-            raise ValueError(
-                f"Client tuple format should be ('ip.ip.ip.ip', port), not " f"{client}"
-            )
 
-    if re.search(r"^((\d?){3}\.){3}(\d\d?\d?):\d(\d?){4}$", client):
-        # Matching: 523.152.135.231:92344   Invalid IP handled by Python
-        # Try IP Address, should be unique
-        split_client = client.split(":")
-        reconstructed_client = []
+        if type_cast == bytes:
+            return content_to_type_cast
 
-        # Checks IP correctness
-        try:
-            reconstructed_client.append(map(int, split_client[0].split(".")))
-        except ValueError:
-            raise ValueError("IP is not numerical (only IPv4 currently supported)")
-        try:
-            reconstructed_client.append(int(split_client[1]))
-        except ValueError:
-            raise ValueError("Port is not numerical (only IPv4 currently supported)")
+        if type_cast in (str, float):
+            return type_cast(content_to_type_cast.decode())
+
+        if type_cast == int:
+            return int(ast.literal_eval(content_to_type_cast.decode()))  # XXX
+
+        # Lists and dicts
+        if type_cast in (list, dict):
+            # JSON
+            try:
+                result = json.loads(content_to_type_cast.decode())
+
+            # Python
+            except json.JSONDecodeError:
+                result = ast.literal_eval(content_to_type_cast.decode())  # XXX
+
+            # Reduce ambiguity
+            if not isinstance(result, type_cast):
+                raise InvalidTypeCast()
+
+            return result
+
+        raise InvalidTypeCast(
+            f"Cannot type cast bytes to {type(type_cast).__name__}."
+            " See `HiSockServer.on` for available type hints."
+        )
+
+    except Exception as e:
+        raise InvalidTypeCast(
+            f"Type casting from {type(content_to_type_cast).__name__} "
+            f"to {type_cast.__name__} failed for function "
+            f'"{func_name}":\n{e}'
+        ) from e
 
-        for subip in reconstructed_client[0]:
-            if not 0 <= subip < 255:
-                raise ValueError(f"{client} is not a valid IP address")
-        if not 0 < reconstructed_client[1] < 65535:
-            raise ValueError(f"{split_client[1]} is not a valid port (1-65535)")
+
+def validate_command_not_reserved(command: str):
+    """
+    Checks for illegal $cmd$ notation (used for reserved functions).
+
+    :param command: The command to check.
+    :type command: str
+
+    :raises ValueError: If the command is reserved.
+    """
+
+    if search(r"\$.+\$", command):
+        raise ValueError(
+            'The format "$command$" is used for reserved functions - '
+            "consider using a different format."
+        )
+
+
+def validate_ipv4(  # NOSONAR (always will return True, but will raise exceptions)
+    ip: Union[str, tuple], require_ip: bool = True, require_port: bool = True
+) -> bool:
+    """
+    Validates an IPv4 address.
+    If the address isn't valid, it will raise an exception.
+    Otherwise, it'll return True
+
+    :param ip: The IPv4 address to validate.
+    :param
+    :param require_ip: Whether or not to require an IP address.
+        If True, it will raise an exception if no IP address is given.
+        If False, this will only check the port.
+    :type require_ip: bool
+    :param require_port: Whether or not to require a port to be specified.
+        If True, it will raise an exception if no port is specified.
+        If False, it will return the address as a tuple without a port.
+    :type require_port: bool
+    :return: True if there were no exceptions.
+    :rtype: Literal["True"]
+
+    :raises ValueError: IP address is not valid
+    """
+
+    if not (require_ip or require_port):
+        return True  # There's nothing to check!
+
+    deconstructed_ip = None
+    if isinstance(ip, str):
+        if not require_ip or not require_port:
+            deconstructed_ip = (ip,)
+        else:
+            deconstructed_ip = ipstr_to_tup(ip)
+    elif isinstance(ip, tuple):
+        deconstructed_ip = ip
+
+    if deconstructed_ip is None or len(deconstructed_ip) == 0:
+        raise ValueError("IP address is empty")
+
+    # Port checking
+    if require_port:
+        port = deconstructed_ip[-1]
+        if isinstance(port, str) and not port.isdigit():
+            raise ValueError(f"Port must be a number, not {port}")
+        if int(port) < 0 or int(port) > 65535:
+            raise ValueError(f"{port} is not a valid port (0-65535)")
+        if not require_ip:
+            return True
+
+    # IP checking
+    ip = deconstructed_ip[0]
+    try:
+        IPv4Address(ip)
+    except ValueError:
+        raise ValueError(f"{ip} is not a valid IPv4 address") from None
+
+    return True
 
 
 def get_local_ip(all_ips: bool = False) -> str:
@@ -319,168 +457,156 @@ def get_local_ip(all_ips: bool = False) -> str:
         format "ip:port"
     :rtype: str
     """
+
     if not all_ips:
         return socket.gethostbyname(socket.gethostname())
 
     return socket.gethostbyname_ex(socket.gethostname())[-1]
 
 
+def _input_ip_address(question: str) -> str:
+    """
+    Asks the user to input an IP address. Returns the address as a string
+    when it is valid.
+
+    :param question: The question to ask the user.
+    :type question: str
+    :return: A valid IPv4 address.
+    :rtype: str
+    """
+
+    ip_address = input(question)
+    try:
+        validate_ipv4(ip_address, require_port=False)
+    except ValueError as e:
+        print(f"\033[91mInvalid IP: {e}\033[0m\n")
+        return _input_ip_address(question)
+    return ip_address
+
+
+def _input_port(question: str) -> int:
+    """
+    Asks the user to enter a port. Returns the port as an integer when it
+    is valid.
+
+    :param question: The question to ask the user.
+    :type question: str
+    :return: A valid port.
+    :rtype: int
+    """
+
+    port = input(question)
+    try:
+        validate_ipv4(port, require_ip=False)
+    except (TypeError, ValueError) as e:
+        print(f"\033[91mInvalid port: {e}\033[0m\n")
+        return _input_port(question)
+    return int(port)
+
+
 def input_server_config(
-    ip_prompt: str = "Enter the IP of where to host the server: ",
-    port_prompt: str = "Enter the Port of where to host the server: ",
+    ip_prompt: str = "Enter the IP for the server: ",
+    port_prompt: str = "Enter the port for the server: ",
 ) -> tuple[str, int]:
     """
     Provides a built-in way to obtain the IP and port of where the server
-    should be hosted, through :func:`input()`
+    should be hosted, through :func:`input`.
 
     :param ip_prompt: A string, specifying the prompt to show when
         asking for IP.
-
-        Default is "Enter the IP of where to host the server: "
     :type ip_prompt: str, optional
     :param port_prompt: A string, specifying the prompt to show when
-        asking for Port
-
-        Default is "Enter the Port of where to host the server: "
+        asking for port
     :type port_prompt: str, optional
-    :return: A two-element tuple, consisting of IP and Port
+    :return: A two-element tuple, consisting of IP and port.
     :rtype: tuple[str, int]
     """
 
-    # Flags
-    ip_range_check = True
-
-    ip = input(ip_prompt)
-
-    if re.search(r"^((\d?){3}\.){3}(\d\d?\d?)[ ]*$", ip):
-        # IP conformity regex
-        split_ip = list(map(int, ip.split(".")))
-        split_ip = [i > 255 for i in split_ip]
-
-        if any(split_ip):
-            ip_range_check = True
-
-    while (
-        ip == "" or not re.search(r"^((\d?){3}\.){3}(\d\d?\d?)[ ]*$", ip)
-    ) or ip_range_check:
-        # If IP not conform to regex, accept input until it
-        # is compliant
-        ip = input(f"\033[91mE: Invalid IP\033[0m\n{ip_prompt}")
-        if re.search(r"^((\d?){3}\.){3}(\d\d?\d?)[ ]*$", ip):
-            split_ip = list(map(int, ip.split(".")))
-            split_ip = [i > 255 for i in split_ip]
-
-            if not any(split_ip):
-                ip_range_check = False
-
-    port = input(port_prompt)
-
-    while (port == "" or not port.isdigit()) or (port.isdigit() and int(port) > 65535):
-        # If port is > 65535, or not numerical, repeat until it is
-        port = input(f"\033[91mE: Invalid Port\033[0m\n{port_prompt}")
-    port = int(port)
-
-    # Returns
-    return ip, port
+    return _input_ip_address(ip_prompt), _input_port(port_prompt)
 
 
 def input_client_config(
     ip_prompt: str = "Enter the IP of the server: ",
-    port_prompt: str = "Enter the Port of the server: ",
-    name_prompt: Union[str, None] = "Enter name to connect as: ",
+    port_prompt: str = "Enter the port of the server: ",
+    name_prompt: Union[str, None] = "Enter name: ",
     group_prompt: Union[str, None] = "Enter group to connect to: ",
-) -> tuple[Union[str, int], ...]:
+) -> tuple[tuple[str, int], Optional[str], Optional[str]]:
     """
     Provides a built-in way to obtain the IP and port of the configuration
-    of the server to connect to
+    of the server to connect to.
 
     :param ip_prompt: A string, specifying the prompt to show when
         asking for IP.
-
-        Default is "Enter the IP of the server: "
     :type ip_prompt: str, optional
     :param port_prompt: A string, specifying the prompt to show when
-        asking for Port
-
-        Default is "Enter the Port of the server: "
+        asking for port.
     :type port_prompt: str, optional
     :param name_prompt: A string, specifying the prompt to show when
-        asking for Client Name
-
-        Default is "Enter name to connect as: " (Pass in None for no input)
+        asking for client name.
     :type name_prompt: Union[str, None], optional
     :param group_prompt: A string, specifying the prompt to show when
-        askign for Client Group
-
-        Default is "Enter group to connect to: " (Pass in None for no input)
+        asking for client group.
     :type group_prompt: Union[str, None], optional
-    :return: A tuple containing the config options of the server
+    :return: A tuple containing the config options of the server. Will
+        filter out the unused options.
     :rtype: tuple[str, int, Optional[str], Optional[int]]
     """
-    # Grabs IP and Port from previously defined function
-    ip, port = input_server_config(ip_prompt, port_prompt)
 
-    # Flags
-    name = None
-    group = None
+    ip, port = _input_ip_address(ip_prompt), _input_port(port_prompt)
+    name, group = None, None
 
-    # If names are enabled
-    if name_prompt is not None:
+    if name_prompt:
         name = input(name_prompt)
-
-        # Repeat until name has an input
-        while name == "":
-            name = input(name_prompt)
-
-    # If groups are enabled
-    if group_prompt is not None:
+    if group_prompt:
         group = input(group_prompt)
 
-        # Repeat until group has an input
-        while group == "":
-            group = input(group_prompt)
-
-    # Return list
-    ret = [ip, port]
-
-    if name is not None:
-        ret.append(name)
-    if group is not None:
-        ret.append(group)
-
-    # Return
-    return tuple(ret)
+    return tuple(filter(None, ((ip, port), name, group)))
 
 
 def ipstr_to_tup(formatted_ip: str) -> tuple[str, int]:
     """
-    Converts a string IP address into a tuple equivalent
+    Converts a string IP address into a tuple equivalent.
 
     :param formatted_ip: A string, representing the IP address.
-
-        Must be in the format "ip:port"
+        Must be in the format "ip:port".
     :type formatted_ip: str
-
-    :return: A tuple, with IP address as the first element, and
-        an INTEGER port as the second element
+    :return: A tuple, with a string IP address as the first element and
+        an integer port as the second element.
     :rtype: tuple[str, int]
+
+    :raises ValueError: If the IP address isn't in the "ip:port" format.
     """
-    ip_split = formatted_ip.split(":")
-    recon_ip_split = (str(ip_split[0]), int(ip_split[1]))
-    return recon_ip_split
+
+    try:
+        ip_split = formatted_ip.split(":")
+        if len(ip_split) != 2:
+            raise IndexError
+        if len(ip_split[0].split(".")) != 4:
+            raise IndexError
+        if int(ip_split[1]) <= 0 or int(ip_split[1]) > 65535:
+            raise IndexError
+
+        recon_ip_split = (str(ip_split[0]), int(ip_split[1]))
+        return recon_ip_split
+    except IndexError:
+        raise ValueError(f"{formatted_ip} is not a valid IP address") from None
 
 
 def iptup_to_str(formatted_tuple: tuple[str, int]) -> str:
     """
-    Converts a tuple IP address into a string equivalent
-
-    This function is like the opposite of ``ipstr_to_tup``
+    Converts a tuple IP address into a string equivalent.
+    This function is the opposite of :func:`ipstr_to_tup`.
 
     :param formatted_tuple: A two-element tuple, containing the IP address and the port.
-        Must be in the format (ip: str, port: int)
+        Must be in the format (ip: str, port: int).
     :type formatted_tuple: tuple[str, int]
-
-    :return: A string, with the format "ip:port"
+    :return: A string, with the format "ip:port".
     :rtype: str
+
+    :raises ValueError: If the IP address isn't in the "ip:port" format.
     """
-    return f"{formatted_tuple[0]}:{formatted_tuple[1]}"
+
+    try:
+        return f"{formatted_tuple[0]}:{formatted_tuple[1]}"
+    except IndexError:
+        raise ValueError(f"{formatted_tuple} is not a valid IP address") from None
diff --git a/setup.cfg b/setup.cfg
index b88034e..08aedd7 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,2 +1,2 @@
 [metadata]
-description-file = README.md
+description_file = README.md
diff --git a/setup.py b/setup.py
index c929b6e..5a3dca4 100644
--- a/setup.py
+++ b/setup.py
@@ -1,8 +1,22 @@
+"""
+This python script will generate hisock eggs and wheels for upload to PyPI.
+Just call `python setup.py sdist bdist_wheel` to generate.
+
+====================================
+Copyright SSS_Says_Snek, 2021-present
+====================================
+"""
+import sys
+import datetime
+import shutil
 import pathlib
 import re
+import subprocess
+from distutils.core import Command
 from setuptools import setup
 
 ROOT = pathlib.Path(__file__).parent  # pathlib.Path object of root (/hisock/)
+DIST = ROOT / "dist"
 
 README = (ROOT / "README.md").read_text()  # Reads README
 NAME = "hisock"
@@ -13,6 +27,8 @@
 VERSION = VERSION.lstrip('__version__ = "').rstrip('"')
 ESCAPED_VERSION = re.escape(VERSION)
 
+custom_cmds = {}
+
 
 class ANSIColors:
     PINK = "\033[95m"
@@ -36,11 +52,119 @@ def color_text(txt, color=None, font_changes=None):
     return f"""{f'{getattr(ANSIColors, color.upper().replace(" ", "_"))}' if color is not None else ''}{f'{getattr(ANSIColors, f"FONTCHANGE_{font_changes.upper()}")}' if font_changes is not None else ''}{txt}{f'{ANSIColors.END * sum([bool(i) for i in [color, font_changes]])}'}"""
 
 
+def add_command(name):
+    def internal(custom_cmd):
+        custom_cmds[name] = custom_cmd
+
+    return internal
+
+
+def clear_directory(directory: pathlib.Path):
+    if not directory.exists():
+        print(color_text(f'[INFO] directory "{directory}" does not exist', "green"))
+        return
+
+    for path in directory.iterdir():
+        if path.is_file():
+            path.unlink()
+        elif path.is_dir():
+            shutil.rmtree(path)
+
+
+#########################
+#   CUSTOM BUILD CMDS   #
+#########################
+
+
+@add_command("test")
+class TestCmd(Command):
+    user_options = []
+
+    def initialize_options(self):
+        pass
+
+    def finalize_options(self):
+        pass
+
+    def run(self):
+        print(
+            color_text(
+                f"\n[COMMAND] Running hisock tests..."
+                f"\n          Using Python: {sys.executable}\n",
+                "green",
+            )
+        )
+
+        try:
+            return subprocess.run([sys.executable, "-m", "pytest"])
+        except subprocess.CalledProcessError:
+            print(color_text("Did you install pytest on this python version?", "red"))
+
+
+@add_command("clean")
+class CleanCmd(Command):
+    user_options = []
+
+    def initialize_options(self):
+        pass
+
+    def finalize_options(self):
+        pass
+
+    def run(self):
+        print(color_text("\n[COMMAND] Cleaning dist and build directories...", "green"))
+
+        build_dir = ROOT / "build"
+
+        clear_directory(build_dir)
+        clear_directory(DIST)
+
+
+@add_command("format")
+class FormatCmd(Command):
+    user_options = []
+
+    def initialize_options(self):
+        pass
+
+    def finalize_options(self):
+        pass
+
+    def run(self):
+        print(
+            color_text(
+                f"\n[COMMAND] Recursively formatting root directory..."
+                f"\n          Using Python: {sys.executable}\n",
+                "green",
+            )
+        )
+        try:
+            return subprocess.run([sys.executable, "-m", "black", ROOT])
+        except subprocess.CalledProcessError:
+            print(color_text("Did you install black on this python version?", "red"))
+
+
+num_nonbuild_cmds = sum([i in list(custom_cmds) for i in sys.argv[1:]])
+no_build = num_nonbuild_cmds == len(sys.argv[1:])
+
 #########################
 #      START BUILD      #
 #########################
 
-print(color_text(f"Building version {VERSION}...\n", "green"))
+if num_nonbuild_cmds == 0:
+    print(color_text(f"Building hisock version {VERSION}...\n", "green"))
+elif num_nonbuild_cmds != len(sys.argv[1:]):
+    print(
+        color_text(
+            f"Building hisock version {VERSION}, then running non-build commands...\n",
+            "green",
+        )
+    )
+else:
+    print(color_text("Running non-build commands...\n", "green"))
+
+    setup(cmdclass=custom_cmds)
+    sys.exit()
 
 #########################
 #   EXAMPLE SUBMODULES  #
@@ -72,57 +196,92 @@ def color_text(txt, color=None, font_changes=None):
 requirements = [
     line.strip() for line in (ROOT / "requirements.txt").read_text().splitlines()
 ]
+requirements_dev = [
+    line.strip()
+    for line in (ROOT / "requirements_contrib.txt").read_text().splitlines()
+]
 print_requirements = "- " + "\n- ".join(requirements)
+print_requirements_dev = "- " + "\n- ".join(requirements_dev)
 
 print(
     color_text(
-        "[INFO] Requirements necessary:\n" f"{print_requirements}\n",
+        f"[INFO] Requirements necessary:\n{print_requirements}\n",
         "green",
     )
 )
 
+print(
+    color_text(f"[INFO] Developer requirements:\n{print_requirements_dev}\n", "green")
+)
+
 #########################
 #  REMOVE OLD VERSIONS  #
 #########################
 
-dist_subdir = ROOT / "dist"
+yes_to_all = False
+
 try:
-    dist_subdir_items = list(dist_subdir.iterdir())
+    dist_subdir_items = list(DIST.iterdir())
 except FileNotFoundError:
     dist_subdir_items = []
     print(color_text("[INFO] dist doesn't exist!\n", "green"))
 
-if dist_subdir.exists():
+if DIST.exists():
     if not dist_subdir_items:
         print(color_text("[INFO] Nothing in dist!", "green"))
     else:
         print(
             color_text(
-                f"[INFO] Found {len(dist_subdir_items)} items, removing...", "green"
+                f"[INFO] Found {len(dist_subdir_items)} items in dist, removing...",
+                "green",
             )
         )
-        for old_build_files in dist_subdir.iterdir():
-            if re.match(
-                f"{NAME}-{ESCAPED_VERSION}\.tar\.gz", old_build_files.name
-            ) or re.match(
-                f"{NAME}-{ESCAPED_VERSION}-py3-none-any\.whl", old_build_files.name
-            ):
+        for old_build_file in DIST.iterdir():
+            if not yes_to_all and VERSION in str(old_build_file):
+                build_file_stats = old_build_file.stat()
+                file_create_time = datetime.datetime.fromtimestamp(
+                    build_file_stats.st_ctime
+                )
+                file_create_time_days = (
+                    datetime.datetime.now() - file_create_time
+                ).days
+
+                indentation = "          "
+
                 print(
                     color_text(
-                        f"[WARNING] Found file {old_build_files.name} with same version!\n"
-                        "          "
+                        f"\n[WARNING] Found file {old_build_file.name} with same version!"
+                        f"\n{indentation}"
                         "If you already pushed this file to PyPI, do NOT push the files being "
-                        "generated right now.",
+                        f"generated right now.\n\n{indentation}File Info:\n{indentation}"
+                        f"  - Creation time: {file_create_time:%a %b %d %Y, %I:%M:%S %p} "
+                        f"({file_create_time_days} days ago)\n{indentation}"
+                        f"  - Size: {build_file_stats.st_size} bytes",
                         "yellow",
                     )
                 )
-            else:
-                print(
-                    color_text(
-                        f"[INFO] Removing build file {old_build_files.name}...", "green"
-                    )
+
+                try:
+                    continue_building = input(
+                        color_text(
+                            "Do you want to continue? ([Y]es, [N]o, [A]ll - yes to all) ",
+                            "red",
+                        )
+                    ).lower()
+                except EOFError:  # Pip install - DO NOT REMOVE THIS
+                    break
+
+                if continue_building == "n":
+                    sys.exit()
+                elif continue_building == "a":
+                    yes_to_all = True
+
+            print(
+                color_text(
+                    f"[INFO] Removing build file {old_build_file.name}...", "green"
                 )
-            old_build_files.unlink()
+            )
+            old_build_file.unlink()
     print()
 
 #########################
@@ -130,6 +289,7 @@ def color_text(txt, color=None, font_changes=None):
 #########################
 
 setup(
+    cmdclass=custom_cmds,
     name=NAME,
     version=VERSION,
     description="A higher-level extension of the socket module, with simpler and more efficient usages",
@@ -147,6 +307,7 @@ def color_text(txt, color=None, font_changes=None):
         "Programming Language :: Python :: 3.7",
         "Programming Language :: Python :: 3.8",
         "Programming Language :: Python :: 3.9",
+        "Programming Language :: Python :: 3.10",
     ],
     install_requires=requirements,
     packages=[
@@ -158,3 +319,6 @@ def color_text(txt, color=None, font_changes=None):
 )
 
 print(color_text(f"\nSuccessfully built {NAME} {VERSION}!", "green"))
+
+if DIST.exists() and not any(DIST.iterdir()):
+    print(color_text("- No build files generated!", "green"))


Test patch:
diff --git a/tests/gather_test.py b/tests/gather_test.py
index 3506bbf..37fb0d1 100644
--- a/tests/gather_test.py
+++ b/tests/gather_test.py
@@ -1,14 +1,15 @@
+
 import hisock
 
 server = hisock.server.ThreadedHiSockServer(("localhost", 6969))
-client = hisock.client.ThreadedHiSockClient(("localhost", 6969), None, None)
+client = hisock.client.ThreadedHiSockClient(("localhost", 6969))
 
 info = {}
 
 
 def finish():
-    server.stop_server()
-    client.stop_client()
+    server.close()
+    client.close()
 
 
 ########################
@@ -33,11 +34,8 @@ def recv(data: str):
 
     finish()
 
-server.start_server()
-client.start_client()
-
-server.join()
-client.join()
+server.start()
+client.start()
 
 def test_serv_to_clt():
     assert info["serv_to_clt"] == "AMOGUS"
diff --git a/tests/test_decorator.py b/tests/test_decorator.py
index d114f1e..d0dbae8 100644
--- a/tests/test_decorator.py
+++ b/tests/test_decorator.py
@@ -1,5 +1,6 @@
 """
 Tests the decorators that make up the core of hisock's receiving system
+Hi, what the heck is this? Please fix this.
 """
 
 import pytest
@@ -14,13 +15,25 @@
 class DummyFuncClassServer:
     def __init__(self):
         self.funcs = {}
-        self.reserved_functions = ["join", "leave", "message"]
+        self._reserved_funcs = {
+            "join": 1,
+            "leave": 1,
+            "message": 3,
+            "name_change": 3,
+            "group_change": 3,
+        }
+        self._unreserved_func_arguments = ("client_data", "message")
 
 
 class DummyFuncClassClient(DummyFuncClassServer):
     def __init__(self):
         super().__init__()
-        self.reserved_functions = ["client_connect", "client_disconnect"]
+        self._reserved_funcs = {
+            "client_connect": 1,
+            "client_disconnect": 1,
+            "force_disconnect": 0,
+        }
+        self._unreserved_func_arguments = ("message",)
 
 
 _ServerDec = serv_on
@@ -31,11 +44,11 @@ def __init__(self):
 
 
 def server_dec(cmd):
-    return _ServerDec(server_dummy, cmd)
+    return _ServerDec(server_dummy, cmd, False, False)
 
 
 def client_dec(cmd):
-    return _ClientDec(client_dummy, cmd)
+    return _ClientDec(client_dummy, cmd, False, False)
 
 
 #################################################
@@ -79,32 +92,36 @@ def test_server_no_typehint(self):
         assert server_dummy.funcs["e"] == {
             "func": func_server_no_typecast,
             "name": func_server_no_typecast.__name__,
-            "type_hint": {"clt": None, "msg": None},
+            "type_hint": {"client_data": None, "message": None},
             "threaded": False,
+            "override": False,
         }
 
     def test_server_two_typecast(self):
         assert server_dummy.funcs["f"] == {
             "func": func_server_two_typecast,
             "name": func_server_two_typecast.__name__,
-            "type_hint": {"clt": str, "msg": int},
+            "type_hint": {"client_data": str, "message": int},
             "threaded": False,
+            "override": False,
         }
 
     def test_server_one_typecast(self):
         assert server_dummy.funcs["g"] == {
             "func": func_server_one_typecast,
             "name": func_server_one_typecast.__name__,
-            "type_hint": {"clt": None, "msg": float},
+            "type_hint": {"client_data": None, "message": float},
             "threaded": False,
+            "override": False,
         }
 
     def test_server_clt_typecast(self):
         assert server_dummy.funcs["h"] == {
             "func": func_server_clt_typecast,
             "name": func_server_clt_typecast.__name__,
-            "type_hint": {"clt": list, "msg": None},
+            "type_hint": {"client_data": list, "message": None},
             "threaded": False,
+            "override": False,
         }
 
 
@@ -113,16 +130,18 @@ def test_client_no_typecast(self):
         assert client_dummy.funcs["i"] == {
             "func": func_client_no_typecast,
             "name": func_client_no_typecast.__name__,
-            "type_hint": None,
+            "type_hint": {"message": None},
             "threaded": False,
+            "override": False,
         }
 
     def test_client_typecast(self):
         assert client_dummy.funcs["j"] == {
             "func": func_client_typecast,
             "name": func_client_typecast.__name__,
-            "type_hint": int,
+            "type_hint": {"message": int},
             "threaded": False,
+            "override": False,
         }
 
     def test_client_exception(self):
diff --git a/tests/test_typecast.py b/tests/test_typecast.py
index c6fc1d3..b9b17de 100644
--- a/tests/test_typecast.py
+++ b/tests/test_typecast.py
@@ -1,71 +1,161 @@
 """
-Tests if the typecast (for the typecasting between bytes and other vals) works
-This is essential to see if the typecasting works. If it doesn't, then
-type casting for hisock will crash. We don't want that!
+Tests if the type-cast works.
 """
 
 import pytest
 
-from hisock.utils import _type_cast, InvalidTypeCast
+from hisock.utils import _type_cast, InvalidTypeCast, ClientInfo, SendableTypes
+from typing import Any
+
+
+class Error:
+    """Sentinel class for expecting to raise an error"""
+
+
+tests = {
+    "bytes": [
+        {"original": "hello", "expected": b"hello"},
+        {"original": 1, "expected": b"1"},
+        {"original": 1.0, "expected": b"1.0"},
+        {"original": True, "expected": b"\x00"},
+        {"original": False, "expected": b""},
+        {"original": None, "expected": b""},
+    ],
+    "str": [
+        {"original": b"hello", "expected": "hello"},
+        {"original": 1, "expected": "1"},
+        {"original": 1.0, "expected": "1.0"},
+        {"original": True, "expected": "True"},
+        {"original": False, "expected": "False"},
+        {"original": None, "expected": ""},
+        {"original": ["a", "b"], "expected": '["a", "b"]'},
+        {"original": {"a": "b"}, "expected": '{"a": "b"}'},
+    ],
+    "int": [
+        {"original": "", "expected": 0},
+        {"original": "1", "expected": 1},
+        {"original": b"", "expected": 0},
+        {"original": b"1", "expected": 1},
+        {"original": 1.6, "expected": 1},
+        {"original": True, "expected": 1},
+        {"original": None, "expected": 0},
+        {"original": False, "expected": 0},
+        {"original": ["a", "b"], "expected": Error},
+        {"original": {"a": "b"}, "expected": Error},
+        {"original": "hello", "expected": Error},
+    ],
+    "float": [
+        {"original": "", "expected": 0.0},
+        {"original": "1", "expected": 1.0},
+        {"original": b"", "expected": 0.0},
+        {"original": b"1", "expected": 1.0},
+        {"original": 1, "expected": 1.0},
+        {"original": 1.6, "expected": 1.6},
+        {"original": True, "expected": 1.0},
+        {"original": None, "expected": 0.0},
+        {"original": False, "expected": 0.0},
+        {"original": ["a", "b"], "expected": Error},
+        {"original": {"a": "b"}, "expected": Error},
+        {"original": "hello", "expected": Error},
+    ],
+    "dict": [
+        {"original": '{"a": "b"}', "expected": {"a": "b"}},
+        {"original": b'{"a": "b"}', "expected": {"a": "b"}},
+        {"original": "", "expected": {}},
+        {"original": "hello", "expected": Error},
+        {"original": ["a", "b"], "expected": Error},
+    ],
+    "list": [
+        {"original": '["a", "b"]', "expected": ["a", "b"]},
+        {"original": b'["a", "b"]', "expected": ["a", "b"]},
+        {"original": "", "expected": []},
+        {"original": {"a": "b"}, "expected": Error},
+        {"original": "hello", "expected": Error},
+    ],
+    "client_info": [
+        {
+            "original": {"ip": ("127.0.0.1", "5000"), "name": "a", "group": None},
+            "expected": ClientInfo(
+                **{"ip": ("127.0.0.1", "5000"), "name": "a", "group": None}
+            ),
+        },
+        {
+            "original": '{"ip": ["127.0.0.1", "5000"], "name": "a", "group": None}"',
+            "expected": Error,
+        },
+        {"original": {"name": "a"}, "expected": Error},  # Missing ip
+    ],
+    "random": [
+        {"original": "hello", "expected": Error},
+    ],
+}
 
 
 class TestServerTypeCast:
-    dummy_func = {"name": "random_function"}
-
-    def test_type_cast_str(self):
-        _str = _type_cast(
-            str, b"Why hello there, fello human!", self.__class__.dummy_func
-        )
-
-        assert _str == "Why hello there, fello human!"
-
-    def test_type_cast_str_raise(self):
-        with pytest.raises(TypeError):
-            _type_cast(
-                str,
-                b"\xff\xfea\x00",  # Encoded in utf-16, so...
-                self.__class__.dummy_func,
+    @staticmethod
+    def _test_type_cast(
+        type_cast: SendableTypes, test: Any, expected: Any, func_name: str
+    ):
+        """
+        Tests a single type-cast.
+
+        :param type: The type to test.
+        :param test: The test-case to test.
+        :param expected: The expected result. If it is an Error, it is expected
+            to raise an InvalidTypeCast error.
+        """
+
+        func_name = f"<test {func_name} {test=} to {type_cast.__name__=}>"
+
+        if expected is Error:
+            with pytest.raises(InvalidTypeCast):
+                _type_cast(
+                    type_cast=type_cast, content_to_type_cast=test, func_name=func_name
+                )
+        else:
+            assert (
+                _type_cast(
+                    type_cast=type_cast, content_to_type_cast=test, func_name=func_name
+                )
+                == expected
             )
 
-    def test_type_cast_int(self):
-        _int = _type_cast(int, b"6969696969696969", self.__class__.dummy_func)
-
-        assert _int == 6969696969696969
-
-    def test_type_cast_int_raise(self):
-        with pytest.raises(TypeError):
-            _type_cast(int, b"This is not int, why???", self.__class__.dummy_func)
-
-    def test_type_cast_float(self):
-        _float = _type_cast(float, b"696969.696969", self.__class__.dummy_func)
+    @pytest.mark.parametrize("test", tests["bytes"])
+    def test_bytes(self, test):
+        self._test_type_cast(
+            bytes, test["original"], test["expected"], func_name="bytes"
+        )
 
-        assert _float == 696969.696969
+    @pytest.mark.parametrize("test", tests["str"])
+    def test_str(self, test):
+        self._test_type_cast(str, test["original"], test["expected"], func_name="str")
 
-    def test_type_cast_float_raise(self):
-        with pytest.raises(TypeError):
-            _type_cast(float, b"This is not float, why???", self.__class__.dummy_func)
+    @pytest.mark.parametrize("test", tests["int"])
+    def test_int(self, test):
+        self._test_type_cast(int, test["original"], test["expected"], func_name="int")
 
-    def test_type_cast_dict_list(self):
-        _dict = _type_cast(dict, b'{"I like": "cheese"}', self.__class__.dummy_func)
-        _list = _type_cast(
-            list, b'["Do", "you", "also", "like", "cheese?"]', self.__class__.dummy_func
+    @pytest.mark.parametrize("test", tests["float"])
+    def test_float(self, test):
+        self._test_type_cast(
+            float, test["original"], test["expected"], func_name="float"
         )
 
-        assert _dict == {"I like": "cheese"}
-        assert _list == ["Do", "you", "also", "like", "cheese?"]
+    @pytest.mark.parametrize("test", tests["dict"])
+    def test_dict(self, test):
+        self._test_type_cast(dict, test["original"], test["expected"], func_name="dict")
 
-    def test_type_cast_dict_list_raise(self):
-        with pytest.raises(TypeError):
-            _type_cast(
-                dict,
-                b"Lolololol",
-            )
-        with pytest.raises(TypeError):
-            _type_cast(list, b"Lolololol")
+    @pytest.mark.parametrize("test", tests["list"])
+    def test_list(self, test):
+        self._test_type_cast(list, test["original"], test["expected"], func_name="list")
 
-    def test_type_cast_invalid_cast(self):
-        invalid = _type_cast(
-            bytearray, b"haha this will not do anything", self.__class__.dummy_func
+    @pytest.mark.parametrize("test", tests["client_info"])
+    def test_client_info(self, test):
+        self._test_type_cast(
+            ClientInfo, test["original"], test["expected"], func_name="client info"
         )
 
-        assert invalid is None
+    @pytest.mark.parametrize("test", tests["random"])
+    def test_random(self, test):
+        self._test_type_cast(
+            any, test["original"], test["expected"], func_name="random"
+        )