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

Issue:
Documentation refactor for beginner tutorial



Discussion / hints:

Fix patch:
diff --git a/Dockerfile b/Dockerfile
deleted file mode 100644
index 37cff3e..0000000
--- a/Dockerfile
+++ /dev/null
@@ -1,10 +0,0 @@
-# syntax=docker/dockerfile:1
-
-FROM python:3.9-slim-buster
-WORKDIR /usr/src/app
-
-COPY requirements.txt .
-RUN pip install --no-cache-dir -r requirements.txt
-COPY . .
-
-RUN pip install -e .
diff --git a/README.md b/README.md
index 5a33f15..c8061fe 100644
--- a/README.md
+++ b/README.md
@@ -32,7 +32,7 @@ Of course, you'd need pip and python for this step.
 ## Examples
 hisock utilizes decorators as the core of receiving messages instead of having 
 if statements handling all of the logic. 
-More in-depth examples are located in the `examples` directory. Here is what a basic 
+More in-depth examples are located in the `examples` directory, including a tic-tac-toe game. Here is what a basic 
 server script would look like using hisock:
 
 #### Server
diff --git a/hisock/cleancode.py b/cleancode.py
similarity index 89%
rename from hisock/cleancode.py
rename to cleancode.py
index 17796fb..52c9338 100644
--- a/hisock/cleancode.py
+++ b/cleancode.py
@@ -1,5 +1,5 @@
 """
-A simple script to automate code cleanups.
+A simple script to automate code cleanups. Not stolen from PygameCommunityBot btw.
 """
 import glob
 import os
diff --git a/docs/source/api.rst b/docs/source/api.rst
deleted file mode 100644
index a4a32ad..0000000
--- a/docs/source/api.rst
+++ /dev/null
@@ -1,95 +0,0 @@
-API Reference
-=============
-
-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:: 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
-
-.. 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
-
-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:: raw_send
-   .. automethod:: recv_raw
-   .. automethod:: send
-   .. 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
-
-hisock.utils
-------------
-A module containing some utilities to either:
-
-1. Help :mod:`hisock.client` and :mod:`hisock.server` run (denoted with leading underscore)
-
-2. Provide functions to use alongside hisock
-
-.. autofunction:: hisock.utils.get_local_ip
-.. autofunction:: hisock.utils.input_client_config
-.. autofunction:: hisock.utils.input_server_config
-.. autofunction:: hisock.utils.ipstr_to_tup
-.. autofunction:: hisock.utils.iptup_to_str
\ No newline at end of file
diff --git a/docs/source/api/client.rst b/docs/source/api/client.rst
new file mode 100644
index 0000000..bf7737c
--- /dev/null
+++ b/docs/source/api/client.rst
@@ -0,0 +1,11 @@
+hisock.client
+=============
+
+A module containing the main client classes and functions, including
+:class:`HiSockClient` and :func:`connect`
+
+.. autoclass:: hisock.client.HiSockClient
+   :members:
+
+.. autofunction:: hisock.client.connect
+
diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst
new file mode 100644
index 0000000..5b40b06
--- /dev/null
+++ b/docs/source/api/index.rst
@@ -0,0 +1,10 @@
+API Reference
+==============
+
+These pages provide you with information about the functions available in ``hisock``.
+
+.. toctree::
+
+   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..cd82881
--- /dev/null
+++ b/docs/source/api/server.rst
@@ -0,0 +1,19 @@
+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
+   :members:
+
+.. autofunction:: hisock.server.start_server
+
diff --git a/docs/source/api/utils.rst b/docs/source/api/utils.rst
new file mode 100644
index 0000000..9b1299f
--- /dev/null
+++ b/docs/source/api/utils.rst
@@ -0,0 +1,14 @@
+hisock.utils
+============
+
+A module containing some utilities to either:
+
+1. Help :mod:`hisock.client` and :mod:`hisock.server` run (denoted with leading underscore)
+
+2. Provide functions to use alongside hisock
+
+.. autofunction:: hisock.utils.get_local_ip
+.. autofunction:: hisock.utils.input_client_config
+.. autofunction:: hisock.utils.input_server_config
+.. autofunction:: hisock.utils.ipstr_to_tup
+.. autofunction:: hisock.utils.iptup_to_str
diff --git a/docs/source/conf.py b/docs/source/conf.py
index 735f39d..396eb5e 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -9,14 +9,10 @@
 
 path = pathlib.Path(os.path.dirname(__file__))
 
-sys.path.append(
-    os.path.join(
-        str(path), str(path.parent.parent)
-    )
-)
+sys.path.append(os.path.join(str(path), str(path.parent.parent)))
 
-sys.path.insert(0, os.path.abspath('.'))
-sys.path.insert(0, os.path.abspath('../../'))
+sys.path.insert(0, os.path.abspath("."))
+sys.path.insert(0, os.path.abspath("../../"))
 # sys.path.insert(
 #     0, os.path.abspath(
 #         '../../'
@@ -26,9 +22,7 @@
 sys.modules["__future__"] = Mock()
 sys.modules["__future__.annotations"] = Mock()
 
-print("Source files live in:", os.path.abspath(
-    '../../'
-))
+print("Source files live in:", os.path.abspath("../../"))
 
 from hisock import constants
 
@@ -66,7 +60,7 @@
 
 # The full version, including alpha/beta/rc tags
 try:
-    release = version_html['tag_name']
+    release = version_html["tag_name"]
 except KeyError:
     # Most likely rate limited
     release = constants.__version__  # Fall back on latest known release
@@ -77,14 +71,14 @@
 # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
 # ones.
 extensions = [
-    'sphinx.ext.duration',
-    'sphinx.ext.autodoc',
-    'sphinx.ext.coverage',
-    'sphinx.ext.intersphinx'
+    "sphinx.ext.duration",
+    "sphinx.ext.autodoc",
+    "sphinx.ext.coverage",
+    "sphinx.ext.intersphinx",
 ]
 
 # Add any paths that contain templates here, relative to this directory.
-templates_path = ['_templates']
+templates_path = ["_templates"]
 
 # List of patterns, relative to source directory, that match files and
 # directories to ignore when looking for source files.
@@ -96,7 +90,7 @@
 # The theme to use for HTML and HTML Help pages.  See the documentation for
 # a list of builtin themes.
 #
-html_theme = 'furo'
+html_theme = "furo"
 
 # Add any paths that contain custom static files (such as style sheets) here,
 # relative to this directory. They are copied after the builtin static files,
diff --git a/docs/source/index.rst b/docs/source/index.rst
index d504195..27235b6 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -12,8 +12,8 @@ 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/>`_
+   If you find something that needs improvement, submit an issue on the
+   `GitHub Repository <https://github.com/SSS-Says-Snek/hisock/>`_
 
 .. toctree::
    :maxdepth: 2
@@ -33,7 +33,7 @@ Table of Contents
 
    quickstart/index
    tutorials/index
-   API Reference <api>
+   API Reference <api/index>
 
 .. toctree::
    :maxdepth: 1
diff --git a/docs/source/quickstart/beginner_tutorial.rst b/docs/source/quickstart/beginner_tutorial.rst
index f4b01a8..3ac85c6 100644
--- a/docs/source/quickstart/beginner_tutorial.rst
+++ b/docs/source/quickstart/beginner_tutorial.rst
@@ -34,6 +34,8 @@ the server data.
    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
 -------------------------
 
@@ -59,6 +61,8 @@ minimize lag time. So, our final server script looks like:
 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
 -------------------------
 
@@ -88,6 +92,8 @@ our final boilerplate client code is:
 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
@@ -135,6 +141,8 @@ attached to it, will automatically call ``handler_thing()``, albeit with less pa
 
 Now that we've done that, let's add functionality to our bland server and client!
 
+----
+
 Adding (some) functionality to our server
 -----------------------------------------
 
@@ -176,6 +184,8 @@ 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
 --------------------------
 
@@ -240,10 +250,12 @@ So, we must modify our client
 
 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, :class:`HiSockClient`s provide a way to send data to the server, with
+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.
@@ -289,6 +301,8 @@ and our server can be
 
 We've successfully made a functional client and server!
 
+----
+
 Conclusion
 ----------
 
diff --git a/docs/source/quickstart/installation.rst b/docs/source/quickstart/installation.rst
index baa918c..2340c4b 100644
--- a/docs/source/quickstart/installation.rst
+++ b/docs/source/quickstart/installation.rst
@@ -2,13 +2,8 @@ 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.
-
-.. 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
+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.
 
 Installing via PyPI
 -------------------
@@ -55,7 +50,7 @@ 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 .``)
+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::
    
diff --git a/docs/source/quickstart/understanding_hisock.rst b/docs/source/quickstart/understanding_hisock.rst
index 27af20b..0479c8e 100644
--- a/docs/source/quickstart/understanding_hisock.rst
+++ b/docs/source/quickstart/understanding_hisock.rst
@@ -34,12 +34,4 @@ of worrying about headers.
    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
+   "Hello World!"
\ No newline at end of file
diff --git a/docs/source/tutorials/index.rst b/docs/source/tutorials/index.rst
index 30c6f3f..2b54f1d 100644
--- a/docs/source/tutorials/index.rst
+++ b/docs/source/tutorials/index.rst
@@ -2,8 +2,8 @@ More 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
+:doc:`Quickstart Tutorial <../quickstart/beginner_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::
diff --git a/examples/basic/example_client.py b/examples/basic/example_client.py
index 09323ec..e790e3b 100644
--- a/examples/basic/example_client.py
+++ b/examples/basic/example_client.py
@@ -11,13 +11,15 @@
 
 def run():
     # Parse `input` results
-    server_to_connect = input("Enter server IP to connect to (Press enter for default of your 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 == '':
+    if server_to_connect == "":
         server_to_connect = get_local_ip()
 
-    if port == '':
+    if port == "":
         port = 6969
     else:
         port = int(port)
@@ -25,27 +27,30 @@ def run():
     name = input("Name? (Press enter for no name) ")
     group = input("Group? (Press enter for no group) ")
 
-    print("======================================= ESTABLISHING CONNECTION =======================================")
+    print(
+        "======================================= ESTABLISHING CONNECTION ======================================="
+    )
 
-    if name == '':
+    if name == "":
         name = None
-    if group == '':
+    if group == "":
         group = None
 
     join_time = time.time()
 
     # Establishes connection to the server
-    s = connect(
-        (server_to_connect, port),
-        name=name, group=group
-    )
+    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")
+        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())
diff --git a/examples/basic/example_server.py b/examples/basic/example_server.py
index 3953ce3..c32cb9a 100644
--- a/examples/basic/example_server.py
+++ b/examples/basic/example_server.py
@@ -31,9 +31,9 @@ def client_join(client_data):
         # 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!")
-        if client_data['name'] is not None:
+        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:
+        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")
@@ -41,7 +41,9 @@ def client_join(client_data):
         # 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())
+        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
@@ -53,9 +55,11 @@ def process(client, process_request: str):
         # 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
-        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(
+            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")):
@@ -69,7 +73,7 @@ def process(client, process_request: str):
         # 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_raw(client["ip"], str(result).encode())
 
     while True:
         server.run()
diff --git a/examples/connectfour/_shared.py b/examples/connectfour/_shared.py
new file mode 100644
index 0000000..2d392dd
--- /dev/null
+++ b/examples/connectfour/_shared.py
@@ -0,0 +1,6 @@
+import enum
+
+class BoardEnum(enum.Enum):
+    NO_PIECE = -1
+    RED = 0
+    YELLOW = 1
diff --git a/examples/connectfour/connectfour_client.py b/examples/connectfour/connectfour_client.py
new file mode 100644
index 0000000..e69de29
diff --git a/examples/connectfour/connectfour_server.py b/examples/connectfour/connectfour_server.py
new file mode 100644
index 0000000..d796fa9
--- /dev/null
+++ b/examples/connectfour/connectfour_server.py
@@ -0,0 +1,10 @@
+import hisock
+import _shared
+
+addr, port = hisock.input_server_config()
+
+server = hisock.HiSockServer((addr, port))
+
+server.start()
+
+print("SUSSSS")
\ No newline at end of file
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 cd2d1f3..0000000
--- a/examples/tictactoe/__init__.py
+++ /dev/null
@@ -1,20 +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 e9d51ee..387587c 100644
--- a/examples/tictactoe/tictactoe_client.py
+++ b/examples/tictactoe/tictactoe_client.py
@@ -1,172 +1,171 @@
-import os
-import sys
-import pathlib
+"""HiSock TicTacToe client side (no GUI)"""
+
+### Setup ###
+from shared import connect_to_server, log_error
+from os import system, name as os_name
+
+# HiSock setup
+print("TicTacToe Client - NO GUI")
+client = connect_to_server()
+
+### Classes ###
+class TicTacToe:
+    board = [
+        [" ", " ", " "],
+        [" ", " ", " "],
+        [" ", " ", " "],
+    ]
+
+    @staticmethod
+    def display_board():
+        system("cls" if os_name == "nt" else "clear")
+
+        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)
+
+
+### 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
 
-PATH = pathlib.Path(__file__)
+        print('Please enter "y" or "n"')
 
-# 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
+    return response
 
-from hisock import (
-    connect
-)
 
+def get_move() -> tuple:
+    """Asks the user for a move and returns it"""
 
-# 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 = ''
+    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
 
-def clear():
-    """Well, no pycharm support, so boo hoo"""
-    if os.name == "nt":
-        os.system("cls")
-    elif os.name == "posix":
-        os.system("clear")
+        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
 
-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=" ")
-
-    server = connect((ip_input, port_input), name)
-
-    print("SUCCESS!")
-    clear()
-    print("Waiting for pairing... ")
-
-    data = Data()
-
-    # Hisock message receive decorators
-    @server.on("game_start")
-    def game_started(opponent: str):
-        print(f"Pairing succeeded! Opponent: \"{opponent}\"")
-        data.tictactoe_opponent = opponent
-        goes_first = server.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
-            server.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))
-
-    @server.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))
-
-        server.send("player_turn", str(move_input - 1).encode())
-
-    @server.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()
-
-    @server.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()
-
-    @server.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()
-
-    @server.on("opp_disc")
-    def opponent_leave(opp_name: str):
-        clear()
-        print(f"Opponent \"{opp_name}\"disconnected; disconnecting from server...")
-        raise SystemExit
+        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():
     while True:
-        # Updates server
-        server.update()
+        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 11d408b..169fc0b 100644
--- a/examples/tictactoe/tictactoe_pygame_client.py
+++ b/examples/tictactoe/tictactoe_pygame_client.py
@@ -1,138 +1,430 @@
-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): ")
 
-    port_input = input("Enter port of server: ")
-    while port_input == '':
-        port_input = input("Enter port of server: ")
-    port_input = int(port_input)
+        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
 
-    name = input("Enter name (MAX LENGTH: 32): ")
-    while name == '' or len(name) > 32:
-        name = input("Enter name (MAX LENGTH: 32): ")
+        print('Please enter "y" or "n"')
 
-    print(f"Connecting to server at {ip_input}:{port_input}...", end=" ")
+    return response
 
-    server = threaded_connect((ip_input, port_input), name)
-    print("SUCCESS!")
 
-    data = Data()
+def move_made(position: tuple):
+    """
+    Send a move to the server
+    :param position: The position of the button that was pressed
+    """
 
-    @server.on("game_start")
-    def game_start(opponent: str):
-        data.game_start = True
-        data.tictactoe_opponent = opponent
-        goes_first = server.recv_raw()
+    TicTacToe.our_turn = False
 
-        if goes_first == b"First":
-            data.turn = True
-            data.letter = "X"
-        else:
-            data.turn = False
-            data.letter = "O"
+    # 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)
 
-    screen = pygame.display.set_mode((400, 400))
+    return row_idx, col_idx
 
-    server.start_client()
 
-    opp_font = load_font(20)
+### Pygame functions ###
+def text_display(message: str, position: tuple, size: int = 48):
+    """Display a text on the screen"""
 
-    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
+    def get_text_rect_surf() -> tuple[pygame.Rect, pygame.Surface]:
+        # Cache
+        if (position, message) in Cache.text_cache:
+            return Cache.text_cache[(position, message)]
+
+        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
+
+
+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
+    """
+
+    button_rect = get_button_rect(position, size)
+    pygame.draw.rect(screen, color, button_rect, border_width)
+
+    return (
+        button_rect.collidepoint(pygame.mouse.get_pos())
+        and pygame.mouse.get_pressed()[0]
+    )
+
+
+### 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"
+                + "_"
             )
-            pygame.draw.line(
-                screen, (0, 0, 0),
-                (20, 133), (380, 133),
-                width=10
+        ]
+        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(f"Username:", (SCREEN_SIZE[0] // 2, 48))
+        text_display(f"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(f"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
             )
-            pygame.draw.line(
-                screen, (0, 0, 0),
-                (20, 266), (380, 266),
-                width=10
+        else:
+            text_display(
+                "Waiting for opponent move...", (SCREEN_SIZE[0] // 2, 24 + 48), size=24
             )
-            
-            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))
 
-            name_txt = opp_font.render(f"You are: {name}", True, (0, 0, 0))
+    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,
+                    )
 
-            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
+                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,
+                )
 
-            screen.blit(opp_txt, (0, 0))
-            screen.blit(name_txt, (0, 20))
+                # Draw an X or O
+                if col != " ":
+                    text_display(col, position_center, size=72)
+                    continue
 
-            if turn_txt is not None:
-                screen.blit(turn_txt, opp_txt.get_rect(topright=(380, 0)))
+                # 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,
+                )
+
+    def draw_waiting_room(self):
+        text_display("Waiting for opponent...", (SCREEN_SIZE[0] // 2, 24), size=24)
+
+    def draw_game_over(self):
+        text_display(f"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.stop_client()
+            break
+        except StopIteration:
+            # This is raised when the player already left ("leave" sent)
+            break
+        except Exception as error:
+            log_error(error)
+            break
 
-            pygame.display.update()
+    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 5bc9a8f..24f27a8 100644
--- a/examples/tictactoe/tictactoe_server.py
+++ b/examples/tictactoe/tictactoe_server.py
@@ -1,171 +1,356 @@
-# Imports
-
-import pathlib
-import random
-import statistics
-import sys
-
-# 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(pathlib.Path(__file__).parent.parent.parent))  # Not needed usually; used to run examples
-
-from hisock import start_server, utils
-
-
-# Data class, as to prevent using those dirty globals
+"""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:
-    def __init__(self):
-        self.boards = []
-
-
-# IP and Port establishment
-
-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: ")
+    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,
+            }
+        )
+
+    @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),
+        )
+
+    @staticmethod
+    def switch_turn():
+        Data.turn = "x" if Data.turn != "x" else "o"
+
+
+class TicTacToe:
+    """Game code for TicTacToe"""
+
+    board = [
+        [" ", " ", " "],
+        [" ", " ", " "],
+        [" ", " ", " "],
+    ]
+    turn = "x"
+
+    @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, reason: bytes = b"unknown"):
+    # 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)
 
-        port_input = int(port_input)
     else:
-        ip_input = utils.get_local_ip()
-        port_input = 6969
+        make_move()
+        return
 
-    print(f"Starting server at {ip_input}:{port_input}...", end=" ")
-    s = start_server((ip_input, port_input))
-    print("SUCCESS!")
+    print(f"Game over!")
 
-    # Tic Tac Toe variable initialization
-    paired_clients = []
-    paired_clients_ip = []
-    x_or_o = []
 
-    data = Data()
+# 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")
 
-    # Hisock message receive decorators
-    @s.on("join")
-    def client_conn(client_info):
-        print(f"Client \"{client_info['name']}\" connected")
+    print(f"{client_info['name']} wants to play again")
 
-        # 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()
-
-            s.send_client(client_info['ip'], "game_start", last_pair[0].encode())
-            s.send_client(last_ip_pair[0], "game_start", client_info['name'].encode())
-
-            if goes_first < 0.5:
-                # Connected client goes first
-                s.send_client_raw(client_info['ip'], b"First")
-                s.send_client_raw(last_ip_pair[0], b"Last")
-                x_or_o.append(["O", "X"])
-            else:
-                # Unpaired client goes first
-                s.send_client_raw(last_ip_pair[0], b"First")
-                s.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']])
-
-    @s.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]
-                        s.send_client(other_ip, "opp_disc", clt_info['name'].encode())
-                        paired_clients_ip.remove(ip_pair)
-        print(paired_clients_ip)
-
-    @s.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:
-                    s.send_client(opponent_ip, "win", str(move).encode())
-                    s.send_client(clt_data['ip'], 'lose', str(move).encode())
-                else:
-                    s.send_client(clt_data['ip'], "win", str(move).encode())
-                    s.send_client(opponent_ip, 'lose', str(move).encode())
-
-        spaces = [
-            i == " " for i in data.boards[idx[0]]
-        ]
+    client_idx = Data.get_client_info(name=client_info["name"])[1]
+    Data.paired_clients[client_idx]["playing"] = True
 
-        if not any(spaces):
-            # No spaces and no winner - Tied
-            no_winner = False  # Even though it's technically true
-            s.send_client(opponent_ip, "tie", str(move).encode())
-            s.send_client(clt_data['ip'], 'tie', str(move).encode())
+    # Both clients are playing check
+    for client in Data.paired_clients:
+        if not client["playing"]:
+            return
 
-        if no_winner:
-            s.send_client(opponent_ip, "player_turn", str(move).encode())
+    start_game()
 
+
+### Run ###
+def run():
     while True:
         try:
-            # Runs server
-            s.run()
-        except utils.NoMessageException:
+            server.run()
+        except (
+            hisock.utils.NoMessageException,
+            hisock.utils.NoHeaderWarning,
+            BrokenPipeError,
+            ConnectionResetError,
+            TypeError,
+        ):
+            # This happens when the server is trying to send Data to a client
+            # that is no longer connected
             pass
+        except KeyboardInterrupt:
+            break
+        except Exception as error:
+            log_error(error)
+            break
+
+    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 c747c88..8ad91f4 100644
--- a/hisock/__init__.py
+++ b/hisock/__init__.py
@@ -1,15 +1,24 @@
-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 .server import (
     start_server,
-    start_threaded_server,
     HiSockServer,
 )  # lgtm [py/unused-import]
-from .client import connect, threaded_connect, HiSockClient  # lgtm [py/unused-import]
+from .client import connect, HiSockClient  # lgtm [py/unused-import]
 from .utils import (  # lgtm [py/unused-import]
     get_local_ip,  # lgtm [py/unused-import]
     input_client_config,
@@ -17,6 +26,5 @@
     ipstr_to_tup,
     iptup_to_str,  # lgtm [py/unused-import]
 )
-from hisock.constants import __version__
 
 import examples  # lgtm [py/unused-import]
diff --git a/hisock/_shared.py b/hisock/_shared.py
new file mode 100644
index 0000000..47fe9b5
--- /dev/null
+++ b/hisock/_shared.py
@@ -0,0 +1,350 @@
+"""
+This module contains shared code for both HiSockServer and HiSockClient.
+
+====================================
+Copyright SSS_Says_Snek, 2022-present
+====================================
+"""
+
+import inspect
+import threading
+import warnings
+from typing import Callable, Union
+
+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}}
+        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 = {}
+
+        # 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,
+        decoded_data: str,
+        content_header: bytes,
+    ):
+        if self.cache_size <= 0:
+            return
+
+        cache_content = content if has_listener else decoded_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_function(self, func_name: str, *args, **kwargs):
+        """
+        Calls a function with the given arguments and returns the result.
+
+        :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,
+            }
+
+            # 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 f1ceeaf..d7de34a 100644
--- a/hisock/client.py
+++ b/hisock/client.py
@@ -13,38 +13,55 @@
 # 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
-import traceback  # traceback, for... tracebacks
-from ipaddress import IPv4Address  # ipaddress, for comparisons between IPs
-
-from typing import Union, Callable, Any
-
-# Utilities
+import socket
+import json  # Handle sending dictionaries
+import errno  # Handle fatal errors with the server
+import warnings  # Non-severe errors
+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,
+        FunctionNotFoundWarning,
         ServerNotRunning,
-        ClientDisconnected,
+        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,
+        FunctionNotFoundWarning,
         ServerNotRunning,
-        ClientDisconnected,
+        MessageCacheMember,
+        ClientInfo,
+        Sendable,
+        Client,
+        _removeprefix,
+        _type_cast,
+        make_header,
         iptup_to_str,
+        validate_ipv4,
     )
+    from _shared import _HiSockBase
 
 
 # ░█████╗░░█████╗░██╗░░░██╗████████╗██╗░█████╗░███╗░░██╗██╗
@@ -53,60 +70,54 @@
 # ██║░░██╗██╔══██║██║░░░██║░░░██║░░░██║██║░░██║██║╚████║╚═╝
 # ╚█████╔╝██║░░██║╚██████╔╝░░░██║░░░██║╚█████╔╝██║░╚███║██╗
 # ░╚════╝░╚═╝░░╚═╝░╚═════╝░░░░╚═╝░░░╚═╝░╚════╝░╚═╝░░╚══╝╚═╝
-#      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__(
@@ -114,701 +125,660 @@ def __init__(
         addr: tuple[str, int],
         name: Union[str, None],
         group: Union[str, None],
-        blocking: bool = True,
         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 = []
-
-        # TLS arguments
-        self.tls_arguments = {"tls": False}  # If TLS is false, then no TLS
-
-        # 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
 
-        # TLS stuff (soon to be added)
-        self.tls = self._TLS(self)
-        self.tls_arguments = {"tls": False}
-        self.called_update = False
-
-        # 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
+        self.sock.setblocking(False)
+
+        # 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": (),
+            },
+        }
+        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 _TLS:
-        """
-        Base class for establishing TLS connections,
-        and getting information about it
-
-        TLS (Transport Layer Security) is a protocol, that basically is
-        used on every internet connection. It establishes
-        a secure connection between the client and the server, to prevent
-        eavesdropping.
+    # Internal methods
 
-        While TLS usually allows clients and servers to pick what "suites"
-        they have available, there is currently only one predefined suite
-        to be used. Of course, as the projects gets bigger, more suites
-        would be added.
-
-        CLASS AND TLS IMPLEMENTATION NOT READY YET - DO NOT USE
+    def _send_client_hello(self):
         """
+        Sends a hello to the server for the first connection.
 
-        def __init__(self, outer):
-            self.outer = outer
-
-        def enable(self, rsa_privkey_filepath=".privkey", suite="default"):
-            # TLS NOT ADDED CURRENTLY; NOT PRIORITY
-            if not self.outer.called_update:
-                self.outer.tls_arguments = {
-                    "tls": True,
-                    "rsa_privkey_filepath": rsa_privkey_filepath,
-                    "suite": suite,
-                }
-
-                dh_num_header = make_header("$DH_NUMS$", self.outer.header_len)
-                self.outer.sock.send(dh_num_header + b"$DH_NUMS$")
-                dh_info = self.outer.recv_raw()
-                print("E", dh_info)
-
-                if dh_info == b"$NOTLS$":
-                    raise TypeError("Server has not enabled TLS")
-            else:
-                raise TypeError("TLS attempted to enable after `update` called")
-
-    def update(self):
-        """
-        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.
-        In some cases, it is recommended to run this in a thread, as to not block the
-        program
+        :raises ClientException: If the client is already connected.
         """
-        self.called_update = True  # I forgot what this does
 
-        if not self._closed:  # Checks if client hasn't been closed with `close`
-            try:
-                while True:
-                    # 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
-
-                    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.keys():
-                        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.funcs["force_disconnect"]["func"]()
-                    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.funcs["client_connect"]["func"](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.funcs["client_disconnect"]["func"](clt_content)
-
-                    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
-                        ):
-                            parse_content = content[len(matching_cmd) + 1 :]
-
-                            # Type Hint -> Type Cast
-                            # (Exceptions need to have "From ValueError")
-                            if func["type_hint"] == str:
-                                # bytes -> str
-                                try:
-                                    parse_content = parse_content.decode()
-                                except UnicodeDecodeError as e:
-                                    raise TypeError(
-                                        f"Type casting from bytes to string failed "
-                                        f"for function \"{func['name']}\":\n           {e}"
-                                    ) from ValueError
-                            elif func["type_hint"] == int:
-                                # bytes -> int
-                                try:
-                                    parse_content = int(parse_content)
-                                except ValueError as e:
-                                    raise TypeError(
-                                        f"Type casting from bytes to int "
-                                        f"failed for function \"{func['name']}\":\n           {e}"
-                                    ) from ValueError
-                            elif func["type_hint"] == float:
-                                # bytes -> float
-                                try:
-                                    parse_content = float(parse_content)
-                                except ValueError as e:
-                                    raise TypeError(
-                                        f"Type casting from bytes to float "
-                                        f"failed for function \"{func['name']}\":\n           {e}"
-                                    ) from ValueError
-
-                            for _type in [list, dict]:
-                                if func["type_hint"] == _type:
-                                    try:
-                                        parse_content = json.loads(parse_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 \"{self.funcs['message']['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 \"{self.funcs['message']['name']}\""
-                                            f":\n           {e}"
-                                        ) from type(e)
-
-                            # Call function
-                            func["func"](parse_content)
-
-                    # Caching
-                    if self.cache_size >= 0:
-                        self.cache.append(
-                            {"header": content_header, "content": content}
-                        )
-
-                        if 0 < self.cache_size < len(self.cache):
-                            self.cache.pop(0)
-
-            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()
+        if self.connected:
+            raise ClientException(
+                f"Client is already connected! (connected {time() - self.connect_time} seconds ago)"
+            )
 
-                    raise SystemExit
+        hello_dict = {"name": self.name, "group": self.group}
+        self._send_raw(f"$CLTHELLO${json.dumps(hello_dict)}")
 
-    class _on:
-        """Decorator used to handle something when receiving command"""
+        self.connected = True
+        self.connect_time = time()
 
-        def __init__(self, outer: HiSockClient, command: str):
-            # `outer` arg is for the HiSockClient instance
-            # `cmd_activation` is the command... on activation (WOW)
-            self.outer = outer
-            self.command = command
+    def _handle_keepalive(self):
+        """Handle a keepalive sent from the server."""
 
-        def __call__(self, func: Callable) -> Callable:
-            """Adds a function that gets called when the client receives a matching command"""
+        self._send_raw("$KEEPACK$")
 
-            # 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
+    # On decorator
 
-            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
-            }
-            self.outer.funcs[self.command] = func_dict
-
-            # Returns the inner function, like a decorator
-            return func
-
-    def on(self, command: str) -> Callable:
+    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)
+        - ``bytes``
+        - ``str``
+        - ``int``
+        - ``float``
+        - ``bool``
+        - ``None``
+        - ``list`` (with the types listed here)
+        - ``dict`` (with the types listed here)
 
-        2. bytes -> float (Will raise exception if bytes is not numerical)
-
-        3. bytes -> str (Will raise exception if there's a unicode error)
-
-        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
-
-        :return: The same function
-            (The decorator just appended the function to a stack
+        :param threaded: A boolean, representing if the function should be run in a thread
+            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).
         :rtype: function
+
+        :raises TypeError: If the number of function arguments is invalid.
         """
-        # Passes in outer to _on decorator/class
-        return self._on(self, command)
 
-    def send(
+        return self._on(self, command, threaded, override)
+
+    # Getters
+    def get_cache(
         self,
-        command: str,
-        content: Union[
-            bytes,
-            dict[
-                Union[str, int, float, bool, None],
-                Union[str, int, float, bool, None],
-            ],
-        ],
-    ):
+        idx: Union[int, slice, None] = None,
+    ) -> list[MessageCacheMember]:
         """
-        Sends a command & content to the server, where it will be interpreted
+        Gets the message cache.
 
-        :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 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]
         """
-        # 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(command.encode() + b" " + content, self.header_len)
 
-        # Sends to server
-        self.sock.send(content_header + command.encode() + b" " + content)
+        if idx is None:
+            return self.cache
+        return self.cache[idx]
 
-    def raw_send(
-        self,
-        content: bytes,
-    ):  # TODO: Add dict-sending support for this method
+    def get_client(
+        self, client: Client, get_as_dict: bool = False
+    ) -> Union[ClientInfo, dict]:
         """
-        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
+        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.
         """
-        # 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$"
+
+        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>",
+        )
+
+        # 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']}"
             )
 
-        # Send to server
-        header = make_header(content, self.header_len)
-        self.sock.send(header + content)
+        # Type cast
+        if get_as_dict:
+            return response
+        return ClientInfo(**response)
 
-    def recv_raw(self) -> bytes:
+    def get_server_addr(self) -> tuple[str, int]:
         """
-        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
+        Gets the address of where the client is connected to.
+
+        :return: A tuple, with the format (str IP, int port)
+        :rtype: tuple[str, int]
         """
-        # 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
+        return self.addr
 
-    def change_name(self, new_name: Union[str, None]):
+    def get_client_addr(self) -> tuple[str, int]:
         """
-        Changes the name of the client
+        Gets the address of the client.
 
-        :param new_name: The new name for the client to be called
-        :type new_name: Union[str, None]
+        :return: A tuple, with the format (IP, port).
+        :rtype: tuple[str, int]
         """
-        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$"
-        )
 
-    def change_group(self, new_group: Union[str, None]):
+        return self.sock.getsockname()
+
+    # Transmit data
+
+    def send(self, command: str, content: Sendable = None):
         """
-        Changes the client's group
+        Sends a command & content to the server.
 
-        :param new_group: The new group name of the client
-        :type new_group: Union[str, None]
+        :param command: A string, containing the command to send
+        :type command: str
+        :param content: The message / content to send
+        :type content: Sendable, optional
         """
-        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$"
+        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 get_cache(
-        self,
-        idx: Union[int, slice, None] = None,
-    ):
-        if idx is None:
-            return self.cache
-        else:
-            return self.cache[idx]
-
-    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]}"
-            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())
+    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
 
-        client = self.recv_raw()
+        :param content: The message / content to send
+        :type content: Sendable, optional
+        """
 
-        print(client)
-        raise NotImplementedError("BRUH IT'S NOT IMPLEMENTED")
+        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 get_server_addr(self) -> tuple[str, int]:
+    # Changers
+
+    def change_name(self, new_name: Union[str, None]):
         """
-        Gets the address of where the hisock client is connected
-        at.
+        Changes the name of the client
 
-        :return: A tuple, with the format (str IP, int port)
-        :rtype: tuple[str, int]
+        :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]
         """
-        return self.addr
 
-    def get_client_addr(self) -> tuple[str, int]:
+        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]):
         """
-        Gets the address of the hisock client that is connected
-        to the server.
+        Changes the client's group.
 
-        :return: A tuple, with the format (str IP, int port)
-        :rtype: tuple[str, int]
+        :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]
         """
-        return self.sock.getsockname()
 
-    def close(self, emit_leave: bool = True):
+        if new_group is None:
+            new_group = self.original_group
+
+        data_to_send = "$CHGROUP$" + new_group
+        self._send_raw(data_to_send)
+
+    # Update
+
+    def _update(self):
         """
-        Closes the client; running `client.update()` won't do anything now
+        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.
 
-        :param emit_leave: Decides if the client will emit `leave` to the server or not
-        :type emit_leave: bool
+        .. warning::
+           Don't call this method on its own; instead use :meth:`start`.
         """
-        # 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$")
-        self.sock.close()
 
+        if self.closed:
+            # This shouldn't happen due to `start` handling it, but just in case...
+            return
 
-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.
+        try:
+            ### Receiving data ###
 
-    .. 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`
-    """
+            self._receiving_data = True
 
-    def __init__(
-        self, addr, name=None, group=None, blocking=True, header_len=16, cache_size=-1
-    ):
-        super().__init__(addr, name, group, blocking, header_len, cache_size)
-        self._thread = threading.Thread(target=self.run)
+            content_header = False
+            # Receive header
+            try:
+                content_header = self.sock.recv(self.header_len)
+            except ConnectionResetError:
+                raise ServerNotRunning(
+                    "Server has stopped running, aborting..."
+                ) from ConnectionResetError
+            except ConnectionAbortedError:
+                # Keepalive timeout reached
+                self.closed = True
+                self.close(emit_leave=False)
+
+            # Most likely server has stopped running
+            if not content_header:
+                print("Connection forcibly closed by server, exiting...")
+                raise SystemExit
+
+            data = self.sock.recv(int(content_header.decode()))
+            self._receiving_data = False
+            decoded_data = data.decode()
+
+            ### Reserved commands ###
+
+            # Handle keepalive
+            if decoded_data == "$KEEPALIVE$":
+                self._handle_keepalive()
+                return
+
+            # Handle force disconnection
+            if decoded_data == "$DISCONN$":
+                self.close(emit_leave=False)  # The server already knows we're gone
+                if "force_disconnect" in self.funcs:
+                    self._call_function("force_disconnect")
+                return
+
+            # Handle new client connection
+            if decoded_data.startswith("$CLTCONN$"):
+                if "client_connect" not in self.funcs:
+                    warnings.warn("client_connect", FunctionNotFoundWarning)
+                    return
+
+                client_data = self._type_cast_client_data(
+                    "client_connect",
+                    _type_cast(
+                        type_cast=dict,
+                        content_to_type_cast=_removeprefix(decoded_data, "$CLTCONN$"),
+                        func_name="<client connect in update>",
+                    ),
+                )
+                self._call_function("client_connect", client_data)
+                return
+
+            # Handle client disconnection
+            if decoded_data.startswith("$CLTDISCONN$"):
+                if "client_disconnect" not in self.funcs:
+                    warnings.warn("client_disconnect", FunctionNotFoundWarning)
+                    return
+
+                client_data = self._type_cast_client_data(
+                    "client_disconnect",
+                    _type_cast(
+                        type_cast=dict,
+                        content_to_type_cast=_removeprefix(
+                            decoded_data, "$CLTDISCONN$"
+                        ),
+                        func_name="<client disconnect in update>",
+                    ),
+                )
+                self._call_function("client_disconnect", client_data)
+                return
+
+            ### Unreserved commands ###
+
+            # Handle random data
+            if not decoded_data.startswith("$CMD$"):
+                return
+
+            has_listener = False  # For cache
+
+            # Get the command and the message
+            command = decoded_data.lstrip("$CMD$").split("$MSG$")[0]
+            content = _removeprefix(decoded_data, f"$CMD${command}$MSG$")
+
+            # No content? (`_removeprefix` didn't do anything)
+            if not content or content == decoded_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
 
-        self._stop_event = threading.Event()
+            else:
+                has_listener = self._handle_recv_commands(command, content)
 
-    def stop_client(self):
-        """Stops the client"""
-        self._closed = True
-        self._stop_event.set()
-        self.sock.close()
+            # No listener found
+            if not has_listener:
+                warnings.warn(
+                    f"No listener found for command {command}",
+                    FunctionNotFoundWarning,
+                )
+                # No need for caching
+                return
+
+            # Caching
+            self._cache(has_listener, command, content, decoded_data, content_header)
+
+        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
+
+            # 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 run(self):
-        """
-        The main while loop to run the thread
+            raise SystemExit from e
 
-        Refer to :class:`HiSockClient` for more details
+    # Stop
 
-        .. 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 close(self, emit_leave: bool = True):
+        """
+        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
         """
-        while self._stop_event:
-            try:
-                self.update()
-            except (OSError, ValueError):
-                break
 
-    def start_client(self):
-        """Starts the main server loop"""
-        self._thread.start()
+        self.closed = True
+        if emit_leave:
+            self._send_raw("$USRCLOSE$")
+        self.sock.close()
 
-    def join(self):
-        """Waits for the thread to be killed"""
-        self._thread.join()
+    # Main loop
 
+    def start(self):
+        """Start the main loop for the client."""
 
-def connect(addr, name=None, group=None, blocking=True, header_len=16, cache_size=-1):
+        def loop():
+            while not self.closed:
+                try:
+                    self._update()
+                except KeyboardInterrupt:
+                    self.close()
+
+        loop_thread = threading.Thread(target=loop, daemon=False)
+        loop_thread.start()
+
+
+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
-    """
-    return HiSockClient(addr, name, group, blocking, header_len, cache_size)
-
 
-def threaded_connect(
-    addr, name=None, group=None, blocking=True, header_len=16, cache_size=-1
-):
+    .. 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())``
     """
-    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 HiSockClient(addr, name, group, header_len, cache_size)
 
 
 if __name__ == "__main__":
-    s = threaded_connect(
-        ("192.168.1.131", 33333), name="Sussus", group="Amogus", cache_size=5
+    print("Testing client!")
+    client = connect(
+        ("127.0.0.1", int(input("Port: "))),
+        name=input("Name: "),
+        group=input("Group: "),
+    )
+
+    print(
+        "The HiSock police are on to you. "
+        "You must change your name and group before they catch you."
     )
-    s.change_name("Burp")
+    client.change_name(input("New name: "))
+    client.change_group(input("New group: "))
 
-    @s.on("Joe")
-    def hehe(_):
+    @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)
+    @client.on("client_disconnect")
+    def on_disconnect(client_data):
+        print(f"{client_data.name} disconnected from the server.")
 
-    @s.on("client_connect")
-    def please(data):
-        print(f"Client {':'.join(map(str, data['ip']))} connected :)")
+    @client.on("force_disconnect")
+    def on_force_disconnect():
+        print("You have been disconnected from the server.")
+        client.close()
+        __import__("os")._exit(0)
 
-    @s.on("client_disconnect")
-    def haha_bois(disconn_data):
-        print(f"Aww man, {':'.join(map(str, disconn_data['ip']))} disconnected :(")
+    @client.on("message", threaded=True)
+    def on_message(message: str):
+        print(f"Message received:\n{message}")
 
-    @s.on("Test")
-    def test(data):
-        print("Group message received:", data)
-        print(s.get_cache(slice(1, 3)))
-        s.send("lol", {"I am": "inevitable"})
+    @client.on("genocide")
+    def on_genocide():
+        print("It's time to die!")
+        client.close()
+        __import__("os")._exit(0)
 
-    @s.on("force_disconnect")
-    def susmogus():
-        print("AAAAAAAAA DISCONNECTED :(((((((")
+    def choices():
+        print(
+            "Your choices are:"
+            "\n\tsend\n\tchange_name\n\tchange_group\n\tset_timer\n\tstop\n\tgenocide"
+        )
+        while True:
+            choice = input("What would you like to do? ")
+            if choice == "send":
+                client.send("broadcast_message", 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")
+            else:
+                print("Invalid choice.")
 
-    @s.on("dicttest")
-    def amogus(yes: dict):
-        print(f"OMG SO COOL I RECEIVED THIS DICT: Does this {yes['Does this']}")
+    choices_thread = threading.Thread(target=choices, daemon=False)
+    choices_thread.start()
 
-    s.start_client()
+    client.start()
diff --git a/hisock/constants.py b/hisock/constants.py
index f824395..6bf4075 100644
--- a/hisock/constants.py
+++ b/hisock/constants.py
@@ -30,7 +30,7 @@
 
 DH_DEFAULT = {"P": DH_P, "N": DH_N}
 
-__version__ = "1.1"
+__version__ = "1.2"
 __name__ = "hisock"
 __copyright__ = "SSS-Says-Snek, 2021-present"
 __author__ = "SSS-Says-Snek"
diff --git a/hisock/server.py b/hisock/server.py
index 700c7d4..efb0466 100644
--- a/hisock/server.py
+++ b/hisock/server.py
@@ -12,45 +12,54 @@
 # 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
-
-# Utilities
-from hisock import constants
+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 (
-        NoHeaderWarning,
-        NoMessageException,
-        receive_message,
+        ServerException,
+        ClientException,
+        FunctionNotFoundWarning,
+        ClientNotFound,
+        GroupNotFound,
+        ClientInfo,
+        Sendable,
+        Client,
         _removeprefix,
-        make_header,
         _dict_tupkey_lookup,
-        _dict_tupkey_lookup_key,
-        _type_cast_server,
+        _type_cast,
+        receive_message,
+        make_header,
+        validate_ipv4,
+        ipstr_to_tup,
     )
-    from . import utils
+    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 (
-        NoHeaderWarning,
-        NoMessageException,
-        receive_message,
+        ServerException,
+        ClientException,
+        FunctionNotFoundWarning,
+        ClientNotFound,
+        GroupNotFound,
+        ClientInfo,
+        Sendable,
+        Client,
         _removeprefix,
-        make_header,
         _dict_tupkey_lookup,
-        _dict_tupkey_lookup_key,
-        _type_cast_server,
+        _type_cast,
+        receive_message,
+        make_header,
+        validate_ipv4,
+        ipstr_to_tup,
     )
-    import utils
+    from _shared import _HiSockBase
 
 
 # ░█████╗░░█████╗░██╗░░░██╗████████╗██╗░█████╗░███╗░░██╗██╗
@@ -59,52 +68,59 @@
 # ██║░░██╗██╔══██║██║░░░██║░░░██║░░░██║██║░░██║██║╚████║╚═╝
 # ╚█████╔╝██║░░██║╚██████╔╝░░░██║░░░██║╚█████╔╝██║░╚███║██╗
 # ░╚════╝░╚═╝░░╚═╝░╚═════╝░░░░╚═╝░░░╚═╝░╚════╝░╚═╝░░╚══╝╚═╝
-#      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
+        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
-
-    :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
+    :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.
+        Default is True.
+    :type keepalive: bool, optional
+
+    :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**
+        **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`
-
-       Also, **only IPv4 is currently supported**
+    :raises TypeError: If the address is not a tuple.
     """
 
     def __init__(
@@ -113,1216 +129,1055 @@ def __init__(
         blocking: bool = True,
         max_connections: int = 0,
         header_len: int = 16,
-        tls: Union[dict, str] = None,
+        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(blocking)
         try:
-            self.sock.bind(addr)
-        except socket.gaierror:
-            raise TypeError("Connection failed (most likely due to invalid IP)")
-        self.sock.listen(max_connections)
-
-        # Function related storage
-        self.funcs = {}
-        self.reserved_functions = [
-            "join",
-            "leave",
-            "message",
-            "name_change",
-            "group_change",
-        ]
+            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",),
+            },
+        }
+        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
+
+        if self._keepalive:
+            keepalive_thread = threading.Thread(
+                target=self._keepalive_thread, daemon=True
+            )
+            keepalive_thread.start()
 
-        # Dictionaries and Lists for client lookup
-        self._sockets_list = [self.sock]
-        self.clients = {}
-        self.clients_rev = {}
+    def __str__(self):
+        """Example: <HiSockServer serving at 192.168.1.133:5000>"""
 
-        if tls is None:
-            self.tls_arguments = {"tls": False}  # If TLS is false, then no TLS
-        else:
-            if isinstance(tls, dict):
-                self.tls_arguments = tls
-            elif isinstance(tls, str):
-                if tls == "default":
-                    self.tls_arguments = {
-                        "rsa_authentication_dir": ".pubkeys",
-                        "suite": "default",
-                        "diffie_hellman": constants.DH_DEFAULT,
-                    }
-        self.called_run = False
-        self._closed = False
-
-    def __str__(self) -> str:
-        """Example: <HiSockServer serving at 192.168.1.133:33333>"""
         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
+
+    def _new_client_connection(
+        self, connection: socket.socket, address: tuple[str, int]
+    ):
+        """
+        Handle the client hello handshake.
+
+        :param connection: The client socket.
+        :type connection: socket.socket
+        :param address: The client address.
+        :type address: tuple[str, int]
 
-    class _TLS:
+        :raises ServerException: If the client is already connected.
+        :raises ClientException: If the client disconnected or had an error.
         """
-        Base class for establishing TLS connections,
-        and getting information about it
 
-        TLS (Transport Layer Security) is a protocol, that basically is
-        used on every internet connection. It establishes
-        a secure connection between the client and the server, to prevent
-        eavesdropping.
+        if connection in self._sockets_list:
+            raise ServerException("Client already connected.")
 
-        While TLS usually allows clients and servers to pick what "suites"
-        they have available, there is currently only one predefined suite
-        to be used. Of course, as the projects gets bigger, more suites
-        would be added.
+        self._sockets_list.append(connection)
 
-        CLASS AND TLS IMPLEMENTATION NOT READY YET - DO NOT USE
+        # 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"].decode(), "$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
+
+        # Send reserved command to existing clients
+        self._send_all_clients_raw(f"$CLTCONN$ {json.dumps(client_data)}".encode())
+
+        if "join" in self.funcs:
+            self._call_function(
+                "join",
+                self._type_cast_client_data(command="join", client_data=client_data),
+            )
+            return
+
+        warnings.warn("join", FunctionNotFoundWarning)
+
+    def _client_disconnection(self, client_socket: socket.socket):
         """
+        Handle a client disconnection.
 
-        def __init__(self, outer):
-            self.outer = outer
+        :raises ClientNotFound: The client wasn't connected to the server.
+        """
 
-    class _on:
-        """Decorator used to handle something when receiving command"""
+        client_data = self.clients[client_socket]
 
-        def __init__(self, outer: HiSockServer, cmd_activation: str):
-            # `outer` arg is for the HiSockServer instance
-            # `cmd_activation` is the command... on activation (WOW)
-            self.outer = outer
-            self.cmd_activation = cmd_activation
+        if client_socket not in self._sockets_list:
+            raise ClientNotFound(f'Client "{client_socket}" is not connected.')
 
-        def __call__(self, func: Callable) -> Callable:
-            """Adds a function that gets called when the server receives a matching command"""
+        try:
+            client_socket.close()
+        except OSError:
+            # Already closed
+            pass
+        self._sockets_list.remove(client_socket)
+        del self.clients[client_socket]
+        del self.clients_rev[
+            (client_data["ip"], client_data["name"], client_data["group"])
+        ]
+        # Note: ``self._unresponsive_clients`` should be handled by the keepalive
 
-            func_args = inspect.getfullargspec(func).args
+    # Keepalive
 
-            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 _handle_keepalive(self, client_socket: socket.socket):
+        """
+        Handles a keepalive acknowledgment sent by a client.
 
-            annots = inspect.getfullargspec(func).annotations
+        :param client_socket: The client socket that sent the acknowledgment.
+        :type client_socket: socket.socket
+        """
 
-            if (
-                self.cmd_activation not in self.outer.reserved_functions
-                or self.cmd_activation == "message"
-            ):
-                # Processes nonreserved commands and reserved `message `
+        if client_socket in self._unresponsive_clients:
+            self._unresponsive_clients.remove(client_socket)
 
-                # `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},
-            }
+    def _keepalive_thread(self):
+        while not self._keepalive_event.is_set():
+            self._keepalive_event.wait(30)
 
-            self.outer.funcs[self.cmd_activation] = func_dict
+            # Send keepalive to all clients
+            if not self._keepalive_event.is_set():
+                for client_socket, client_data in self.clients.items():
+                    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)
+
+            # Keepalive response wait is over, remove the unresponsive clients
+            if not self._keepalive_event.is_set():
+                for client_socket in self._unresponsive_clients:
+                    self.disconnect_client(
+                        self.clients[client_socket]["ip"], force=True, call_func=True
+                    )
+                self._unresponsive_clients.clear()
 
-            # Returns inner function, like a decorator would do
-            return func
+    # On decorator
 
-    def on(self, command: str) -> Callable:
+    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)
+        - ``bytes``
+        - ``str``
+        - ``int``
+        - ``float``
+        - ``bool``
+        - ``None``
+        - ``list`` (with the types listed here)
+        - ``dict`` (with the types listed here)
 
-        2. bytes -> str (Will raise exception if there's a unicode error)
-
-        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
-
-        :return: The same function (The decorator just appended the function to a stack)
+        :param threaded: A boolean, representing if the function should be run in a thread
+            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)
 
-    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.
-        :return:
-        """
-        self._closed = True
-        self.disconnect_all_clients()
-        self.sock.close()
+        return self._on(self, command, threaded, override)
 
-    def disconnect_client(
-        self, client: str
-    ):  # TODO: WILL ADD MORE DIVERSE SUPPORT FOR ARGS
+    # Getters
+
+    def _get_client_from_name_or_ip_port(self, client: Client) -> socket.socket:
         """
-        Disconnects a specific client
-        Different formats of the client is supported. It can be:
+        Gets a client socket from a name or tuple in the form of (ip, port).
 
-        - An IP + Port format, written as "ip:port"
+        :param client: The name or tuple of the client.
+        :type client: Client
 
-        - A client name, if it exists
+        :return: The socket of the client.
+        :rtype: socket.socket
 
-        :param client: The client to disconnect. The format could be either by IP+Port,
-            or a client name
+        :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.
         """
-        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}"
-                )
-
-        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 = []
-
-            # 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)"
-                )
 
-            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")
-            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):
-        """Disconnect all clients."""
-        disconn_header = make_header(b"$DISCONN$", self.header_len)
-        for client in self.clients:
-            client.send(disconn_header + b"$DISCONN$")
-
-    def send_all_clients(
-            self, 
-            command: str, 
-            content: Union[bytes, dict[
-                Union[str, int, float, bool, None], 
-                Union[str, int, float, bool, None]
-            ]
-        ]
-    ):
-        """
-        Sends the commmand and content to *ALL* clients connected
+        return ret_client_socket
 
-        :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]
+    def _get_all_client_sockets_in_group(self, group: str) -> Iterable[socket.socket]:
         """
-        if isinstance(content, dict):
-            content = json.dumps(content).encode()
+        An iterable that returns all client sockets in a group
 
-        content_header = make_header(command.encode() + b" " + content, self.header_len)
-        for client in self.clients:
-            client.send(content_header + command.encode() + b" " + content)
+        :param group: The group to get the sockets from.
+        :type group: str
 
-    def send_group(
-            self, 
-            group: str,
-            command: str, 
-            content: Union[bytes, dict[
-                Union[str, int, float, bool, None], 
-                Union[str, int, float, bool, 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.
+        :return: An iterable of client sockets in the group.
+        :rtype: Iterable[socket.socket]
 
-        :param group: A string, representing the group to send data to
-        :type group: str
-        :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
+        .. note::
+           If the group does not exist, an empty iterable is returned.
         """
-        # 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")
-        else:
-            if isinstance(content, dict):
-                content = json.dumps(content).encode()
+        return _dict_tupkey_lookup(group, self.clients_rev, idx_to_match=2)
 
-            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 send_client(
-            self, 
-            client: Union[str, tuple[str, int]], 
-            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 client.
-        Different formats of the client is supported. It can be:
+        Gets all clients from a specific group.
 
-        - An IP + Port format, written as "ip:port"
+        .. note::
+            If you want to get them from ``clients_rev`` directly, use
+            :meth:`_get_all_client_sockets_in_group` instead.
 
-        - A client name, if it exists
+        :param group: A string, representing the group to look up
+        :type group: str
 
-        - A tuple with an (IP, Port) format
+        :raises GroupNotFound: Group does not exist
 
-        :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]
-        :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
+        :return: A list of dictionaries of clients in that group, containing
+          the address, name, group, and socket
+        :rtype: list
         """
-        if isinstance(content, dict):
-            content = json.dumps(content).encode()
 
-        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)"
+        mod_group_clients = []  # Will be a list of dicts
 
-        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}"
-                )
+        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 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 len(mod_group_clients) == 0:
+            raise GroupNotFound(f'Group "{group}" does not exist.')
 
-            # 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)"
-                )
-
-            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 mod_group_clients
 
-            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")
+    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.
 
-            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
+        :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
 
-                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')
+        :return: A list of dictionaries, with the clients
+        :rtype: list[dict, ...]
+        """
 
-            if isinstance(content, dict):
-                content = json.dumps(content).encode()
+        clients = list(self.clients.values())
 
-            content_header = make_header(
-                command.encode() + b" " + content, self.header_len
-            )
+        if key is None:
+            return clients
 
-            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()))}"
-                )
+        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))
 
-            client_sock[0].send(content_header + command.encode() + b" " + content)
+        return filter_clients
 
-    def send_client_raw(
-            self, 
-            client, 
-            content: bytes
-        ): # TODO: Add dict-sending support to this method
+    def get_client(
+        self, client: Union[str, tuple[str, int]]
+    ) -> dict[str, Union[str, socket.socket]]:
         """
-        Sends data to a specific client, *without a command*
-        Different formats of the client is supported. It can be:
+        Gets the client data for a client from a name or tuple in the form of (ip, port).
 
-        - An IP + Port format, written as "ip:port"
+        :return: The client data without the socket.
+        :rtype: dict
 
-        - A client name, if it exists
+        :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.
+        """
 
-        - A tuple with an (IP, Port) format
+        return self.clients[self._get_client_from_name_or_ip_port(client)]
 
-        :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]
-        :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
+    def get_addr(self) -> tuple[str, int]:
         """
-        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)"
+        Gets the address of where the HiSock server is serving at.
 
-        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}"
-                )
+        :return: A tuple of the address in the form of (ip, port)
+        :rtype: tuple[str, int]
+        """
 
-        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)"
-                )
+        return self.addr
 
-            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)")
+    # Transmit data
 
-            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")
+    def send_all_clients(self, command: str, content: Sendable = None):
+        """
+        Sends the command and content to *ALL* clients connected.
 
-            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
+        :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
+        """
 
-                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')
+        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 isinstance(content, dict):
-                content = json.dumps(content).encode()
+    def _send_all_clients_raw(self, content: Sendable = None):
+        """
+        Sends the command and content to *ALL* clients connected *without a command*.
 
-            content_header = make_header(content, self.header_len)
+        .. note::
+           This has been deprecated for dynamic arguments.
+           Use :meth:`send_all_clients` instead.
 
-            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()))}"
-                )
+        .. warning::
+           The server will throw out any data that is not an unreserved
+           or reserved command.
 
-            # Sends to client
-            client_sock[0].send(content_header + content)
+        :param content: The message / content to send
+        :type content: Sendable
+        """
 
-    def send_group_raw(
-            self, 
-            group: str, 
-            content: bytes
-        ): # TODO: Add dict-sending support to this method
+        content_header = make_header(content, self.header_len)
+        for client in self.clients:
+            client.send(content_header + content)
+
+    def send_group(
+        self, group: Union[str, ClientInfo], command: str, content: Sendable = None
+    ):
         """
-        Sends data to a specific group, without commands.
+        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.
 
-        Non-command-attached content is recommended to be used alongside with
-        :meth:`HiSockClient.recv_raw`
+        :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]
 
-        :param group: A string, representing the group to send data to
-        :type group: str
-        :param content: A bytes-like object, with the content/message
-            to send
-        :type content: bytes
-        :raise TypeError: The group does not exist
+        :raises TypeError: If 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")
-        else:
-            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 run(self):
+        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)
+
+    def send_client(self, client: Client, command: str, content: Sendable = None):
         """
-        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
+        Sends data to a specific client.
+
+        :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
+
+        :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.
         """
-        self.called_run = True
 
-        if not self._closed:
-            # gets all sockets from select.select
-            read_sock, write_sock, exception_sock = select.select(
-                self._sockets_list, [], self._sockets_list
-            )
+        if isinstance(client, ClientInfo):
+            client = client.ip
 
-            for notified_sock in read_sock:
-                # loops through all sockets
-                if notified_sock == self.sock:  # Got new connection
-                    connection, address = self.sock.accept()
+        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:
 
-                    # Handle client hello
-                    client = receive_message(connection, self.header_len)
+        :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
 
-                    client_hello = _removeprefix(client["data"].decode(), "$CLTHELLO$ ")
-                    client_hello = json.loads(client_hello)
+        :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.
+        """
 
-                    # Establishes socket lists and dicts
-                    self._sockets_list.append(connection)
+        if isinstance(client, ClientInfo):
+            client = client.ip
 
-                    clt_info = {
-                        "ip": address,
-                        "name": client_hello["name"],
-                        "group": client_hello["group"],
-                    }
+        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
+        )
 
-                    self.clients[connection] = clt_info
-                    self.clients_rev[
-                        (address, client_hello["name"], client_hello["group"])
-                    ] = connection
+    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.
+        :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: Union[bytes, dict]
+        :raise TypeError: The group does not exist, or the client
+            is not in a group (``ClientInfo``)
+        """
 
-                    if "join" in self.funcs:
-                        # Reserved function - Join
-                        self.funcs["join"]["func"](clt_info)
+        if isinstance(group, ClientInfo):
+            client = group
+            group = client.group  # Please don't confuse this
 
-                    # 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 group is None:
+                raise TypeError(f"Client {client} does not belong to a group")
 
-                    for sock_client in clt_to_send:
-                        sock_client.send(
-                            clt_cnt_header
-                            + f"$CLTCONN$ {json.dumps(clt_info)}".encode()
-                        )
+        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)
 
-                else:
-                    # "header" - The header of the msg, mostly not needed
-                    # "data" - The actual data/content of the msg
-                    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._sockets_list.remove(notified_sock)
-                        del self.clients[notified_sock]
-                        del self.clients_rev[
-                            next(
-                                _dict_tupkey_lookup_key(
-                                    client_disconnect, self.clients_rev
-                                )
-                            )
-                        ]
-
-                        if "leave" in self.funcs:
-                            # Reserved function - Leave
-                            self.funcs["leave"]["func"](
-                                {
-                                    "ip": client_disconnect,
-                                    "name": more_client_info["name"],
-                                    "group": more_client_info["group"],
-                                }
-                            )
-
-                        # Send reserved functions to existing clients
-                        clt_dcnt_header = make_header(
-                            f"$CLTDISCONN$ {json.dumps(more_client_info)}",
-                            self.header_len,
-                        )
+    # Disconnect
 
-                        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]
-
-                        if message["data"] == b"$DH_NUMS$":
-                            if not self.tls_arguments["tls"]:
-                                # The server's not using TLS
-                                no_tls_header = make_header("$NOTLS$", self.header_len)
-                                notified_sock.send(no_tls_header + b"$NOTLS$")
-                            continue
-                        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.funcs["name_change"]["func"](
-                                        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.funcs["group_change"]["func"](
-                                        clt_dict, old_group, new_group
-                                    )
-
-                        for matching_cmd, func in self.funcs.items():
-                            if message["data"].startswith(matching_cmd.encode()):
-                                parse_content = message["data"][len(matching_cmd) + 1 :]
-                                temp_parse_content = _type_cast_server(
-                                    func["type_hint"]["msg"],
-                                    parse_content,
-                                    func,
-                                )
-                                if temp_parse_content is not None:
-                                    parse_content = temp_parse_content
-                                elif temp_parse_content is None:
-                                    raise utils.InvalidTypeCast(
-                                        f"{func['type_hint']['msg']} is an invalid "
-                                        f"type cast!"
-                                    )
-
-                                func["func"](clt_data, parse_content)
-
-                        if "message" in self.funcs:
-                            # Reserved function - message
-                            inner_clt_data = self.clients[notified_sock]
-                            parse_content = message["data"]
-
-                            ####################################################
-                            #         Type hinting -> Type casting             #
-                            ####################################################
-
-                            temp_parse_content = _type_cast_server(
-                                self.funcs["message"]["type_hint"]["msg"],
-                                message["data"],
-                                self.funcs["message"]
-                            )
-
-                            if temp_parse_content is not None:
-                                parse_content = temp_parse_content
-                            elif temp_parse_content is None:
-                                raise utils.InvalidTypeCast(
-                                    f"{self.funcs['message']['type_hint']['msg']} is an invalid "
-                                    f"type cast!"
-                                )
-
-                            self.funcs["message"]["func"](inner_clt_data, parse_content)
-
-    def get_group(
-            self, group: str
-        ) -> list[dict[str, Union[str, socket.socket]]]:
+    def disconnect_client(
+        self, client: Client, force: bool = False, call_func: bool = False
+    ):
+        """
+        Disconnects a specific client.
+
+        :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.
         """
-        Gets all clients from a specific group
 
-        :param group: A string, representing the group to look up
-        :type group: str
-        :raise TypeError: Group does not exist
+        if isinstance(client, ClientInfo):
+            client = client.ip
 
-        :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 = []
+        client_socket = self._get_client_from_name_or_ip_port(client)
+        client_data = self.clients[client_socket]
 
-        if len(group_clients) == 0:
-            raise TypeError(f"Group {group} does not exist")
+        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:
+            if "leave" in self.funcs:
+                self._call_function(
+                    "leave",
+                    self._type_cast_client_data(
+                        command="leave", client_data=client_data
+                    ),
+                )
+                return
+            warnings.warn("leave", FunctionNotFoundWarning)
 
-        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)
+    def disconnect_all_clients(self, force=False):
+        """Disconnect all clients."""
 
-        return mod_group_clients
+        if not force:
+            self._send_all_clients_raw(b"$DISCONN$")
+            return
 
-    def get_all_clients(
-            self, 
-            key: Union[Callable, str] = None
-        ) -> list[dict[str, str]]: # TODO: Add socket output as well
+        for conn in self._sockets_list:
+            conn.close()
+
+        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
+
+    # Run
+
+    def _run(self):
         """
-        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
+        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.
 
-        :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, ...]
+        .. warning::
+           Don't call this method on its own; instead use :meth:`start`.
         """
-        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)
 
-        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))
+        if self.closed:
+            return
 
-        if filter_clts:
-            return filter_clts
-        return clts
+        read_socket, write_socket, exception_socket = select.select(
+            self._sockets_list, [], self._sockets_list
+        )
 
-    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:
+        client_socket: socket.socket
+        for client_socket in read_socket:
+            ### Reserved commands ###
 
-        1. The client name
+            # Handle bad client
+            if client_socket.fileno() == -1:
+                self.disconnect_client(
+                    self.clients[client_socket]["ip"], force=True, call_func=False
+                )
+                continue
 
-        2. The client IP+Port
+            # Handle new connection
+            if client_socket == self.socket:
+                self._new_client_connection(*self.socket.accept())
+                continue
 
-        3. The client IP+Port, in a 2-element tuple
+            ### Receiving data ###
+            decoded_data: str = ""
 
-        :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
+            # {"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 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}"
-                )
+            if isinstance(raw_data, dict):
+                data = raw_data["data"]
+                decoded_data = data.decode()
 
-        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)"
-                )
+                client_data = self.clients[client_socket]
+            except KeyError:
+                raise ClientNotFound(
+                    "Client data not found, but is not a new client."
+                ) from KeyError
 
-            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)")
+            ### Reserved commands ###
 
-            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
+            # Handle client disconnection
+            if (
+                not raw_data  # Most likely client disconnect, could be client error
+                or decoded_data.startswith("$USRCLOSE$")
+            ):
+                try:
+                    self.disconnect_client(
+                        client_data["ip"], force=False, call_func=True
                     )
-                )
-                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")
-        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
+                except BrokenPipeError:  # UNIX
+                    # Client is already gone
+                    pass
+                continue
+
+            # Handle keepalive acknowledgement
+            if decoded_data.startswith("$KEEPACK$"):
+                self._handle_keepalive(client_socket)
+                continue
+
+            # Get client
+            if decoded_data.startswith("$GETCLT$"):
+                try:
+                    client_identifier = _removeprefix(decoded_data, "$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
+
+            # Change name or group
+            for matching_reserve, key in zip(
+                ("$CHNAME$", "$CHGROUP$"), ("name", "group")
+            ):
 
-            client_sock = list(
-                _dict_tupkey_lookup(client, mod_clients_rev, idx_to_match=1)
-            )
+                if not decoded_data.startswith(matching_reserve):
+                    continue
 
-            if len(client_sock) == 0:
-                raise TypeError(f'Client with name "{client}"does not exist')
-            elif 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()))}"
-                )
+                change_to = _removeprefix(decoded_data, matching_reserve)
 
-            client_info = next(
-                _dict_tupkey_lookup_key(client, self.clients_rev, idx_to_match=1)
-            )
+                # Resetting
+                if change_to == "":
+                    change_to = client_data[key]
 
-            client_dict = {
-                "ip": client_info[0],
-                "name": client_info[1],
-                "group": client_info[2],
-                "socket": client_sock[0],
-            }
+                # Change it
+                changed_client_data = client_data.copy()
+                changed_client_data[key] = change_to
+                self.clients[client_socket] = changed_client_data
 
-            return client_dict
+                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_addr(self) -> tuple[str, int]:
-        """
-        Gets the address of where the hisock server is serving
-        at.
+            ### Unreserved commands ###
 
-        :return: A tuple, with the format (str IP, int port)
-        """
-        return self.addr
+            if not decoded_data.startswith("$CMD$"):
+                return  # Random data? No need to cache anyways...
 
+            has_listener = False  # For cache
 
-class _BaseThreadServer(HiSockServer):
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
+            # Get command and message
+            command = decoded_data.lstrip("$CMD$").split("$MSG$")[0]
+            content = _removeprefix(decoded_data, "$CMD$" + command + "$MSG$")
 
-    def _run(self):
-        HiSockServer.run(self)
+            # No content? (`_removeprefix` didn't do anything)
+            if not content or content == decoded_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
 
-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`
-    """
+                has_listener = True
+
+                # 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"].keys()) == 1:
+                    arguments = (type_casted_client_data,)
+                # client_data, message
+                elif len(func["type_hint"].keys()) >= 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
 
-    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)
+            else:
+                has_listener = self._handle_recv_commands(command, content)
 
-        self._stop_event = threading.Event()
+            # No listener found
+            if not has_listener:
+                warnings.warn(
+                    f"No listener found for command {command}",
+                    FunctionNotFoundWarning,
+                )
+                # No need for caching
+                return
 
-    def stop_server(self):
-        """Stops the server"""
-        self._stop_event.set()
-        self.sock.close()
+            # Caching
+            self._cache(
+                has_listener, command, content, decoded_data, raw_data["header"]
+            )
 
-    def run(self):
-        """
-        The main while loop to run the thread
+            # Call `message` function
+            if "message" in self.funcs:
+                self._call_function(
+                    "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 <command>",
+                    ),
+                    _type_cast(
+                        type_cast=self.funcs["message"]["type_hint"]["message"],
+                        content_to_type_cast=content,
+                        func_name="message <message>",
+                    ),
+                )
 
-        Refer to :class:`HiSockServer` for more details
+    # Stop
 
-        .. 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 close(self):
+        """
+        Closes the server; ALL clients will be disconnected, then the
+        server socket will be closed.
+
+        Running `server.run()` won't do anything now.
         """
-        while self._stop_event:
-            try:
-                self._run()
-            except (OSError, ValueError):
-                break
 
-    def start_server(self):
-        """Starts the main server loop"""
-        self._thread.start()
+        self.closed = True
+        self._keepalive_event.set()
+        self.disconnect_all_clients()
+        self.socket.close()
 
-    def join(self):
-        """Waits for the thread to be killed"""
-        self._thread.join()
+    # Main loop
+
+    def start(self):
+        """Start the main loop for the server."""
+
+        def loop():
+            while not self.closed:
+                try:
+                    self._run()
+                except KeyboardInterrupt:
+                    self.close()
+
+        loop_thread = threading.Thread(target=loop, daemon=False)
+        loop_thread.start()
 
 
 def start_server(addr, blocking=True, 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)
 
 
-def start_threaded_server(addr, blocking=True, max_connections=0, header_len=16):
-    """
-    Creates a :class:`ThreadedHiSockServer` instance. See :class:`ThreadedHiSockServer`
-    for more details
+if __name__ == "__main__":
+    print("Testing server!")
+    server = start_server(("127.0.0.1", int(input("Port: "))))
+
+    @server.on("join")
+    def on_join(client_data):
+        print(
+            f"{client_data.name} has joined! "
+            f'Their IP is {":".join(map(str, client_data.ip))}. '
+            f'Their group is {client_data["group"]}.'
+        )
 
-    :return: A :class:`ThreadedHiSockServer` instance
-    """
-    return ThreadedHiSockServer(addr, blocking, max_connections, header_len)
+    @server.on("leave")
+    def on_leave(client_data):
+        print(f"{client_data.name} has 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}".'
+        )
 
-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['ip'], "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.disconnect_all_clients()
-
-    @s.on("lol")
-    def lolol(clt_info, dict_stuff: dict):
-        print(f"Cool, {clt_info['ip']} sent out: {dict_stuff}. What's cool is that I am {dict_stuff['I am']}")
-
-    # @s.on("message")
-    # def why(client_data, message: str):
-    #     print("Message reserved function aaa")
-    #     print("Client data:", client_data)
-    #     print("Message:", message)
-
-    @s.on("Sussus")
-    def a(_, msg):  # _ actually is clt_data
-        s.send_all_clients("pog", msg)
-
-    while True:
-        s.run()
+    @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("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", None)
+
+    server.start()
diff --git a/hisock/utils.py b/hisock/utils.py
index af7b37d..9be43f0 100644
--- a/hisock/utils.py
+++ b/hisock/utils.py
@@ -14,19 +14,15 @@
 from __future__ import annotations
 
 import json
-import re
+import pathlib
 import socket
-from typing import Union, Optional, Any
+from typing import Union, Any, Optional, Type
+from ipaddress import IPv4Address
+from re import search
+import builtins
+import sys
 
-
-# __all__ = [
-#     'make_header', 'receive_message',
-#     'get_local_ip', 'ipstr_to_tup',
-#     'iptup_to_str'
-# ]
-
-
-# Some custom exceptions
+# Custom exceptions
 class ClientException(Exception):
     pass
 
@@ -51,21 +47,108 @@ 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
 
 
+class MessageCacheMember:
+    _available_attrs = ["header", "content", "called", "command"]
+
+    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)
+        self.command = message_dict.get("command", _Sentinel)
+
+        for key, values in dict(self.__dict__).items():
+            if values is _Sentinel:
+                del self.__dict__[key]
+
+    def __str__(self):
+        return f"<MessageCacheMember: {self.content}>"
+
+    def __repr__(self):
+        return self.__str__()
+
+
+class ClientInfo:
+    def __init__(self, ip, name, group):
+        self.ip = tuple(ip)  # _type_cast converts tuple to list to be JSON-serializable
+        self.name = name
+        self.group = group
+
+        self.client_dict = {"ip": self.ip, "name": self.name, "group": self.group}
+        self.ip_as_str = f"{self.ip[0]}:{self.ip[1]}"
+
+    def __getitem__(self, item):
+        return self.client_dict[item]
+
+    def __str__(self):
+        return f"<ClientInfo: IP: {self.ip_as_str}, Name: {self.name}, Group: {self.group}>"
+
+
+class File:
+    def __init__(self, file_path: Union[str, pathlib.Path]):
+        # TODO: implement this!
+        self.file_path = file_path
+
+
+# Custom type hints
+Sendable = Union[
+    bytes,
+    str,
+    int,
+    float,
+    None,
+    ClientInfo,
+    list[Union[str, int, float, bool, None, dict, list]],
+    dict[
+        Union[str, int, float, bool, None, dict, list],
+        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
@@ -74,17 +157,17 @@ 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
 
 
 def receive_message(
-        connection: socket.socket, 
-        header_len: int
-    ) -> dict[str, bytes]:
+    connection: socket.socket, header_len: int
+) -> Union[dict[str, bytes], bool]:
     """
     Receives a message from a server or client.
 
@@ -97,19 +180,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}
-        raise NoMessageException("No header received, aborting...")
+    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(
@@ -117,156 +200,235 @@ def _removeprefix(
     prefix: Union[str, bytes],
 ) -> Union[str, bytes]:
     """A backwards-compatible alternative of str.removeprefix"""
+
     if string.startswith(prefix):
         return string[len(prefix) :]
-    else:
-        return string[:]
+    return string[:]
 
 
 def _dict_tupkey_lookup(
-        multikey: Any, 
-        _dict: dict, 
-        idx_to_match: Union[int, None] = None
-    ) -> Any:
+    multikey: Any, _dict: dict, idx_to_match: Union[int, None] = None
+) -> Any:
     """
     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(
-        multikey: Any, 
-        _dict: dict, 
-        idx_to_match: Union[int, None] = None
-    ) -> Any:
+    multikey: Any, _dict: dict, idx_to_match: Union[int, None] = None
+) -> Any:
     """
     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_server(
-        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)}"
-            )
-    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.
+    """
+
+    if type_cast is None:
+        return content_to_type_cast
+
+    try:
+        # Convert content_to_type_cast to bytes
+        if not isinstance(content_to_type_cast, bytes):
+            if isinstance(content_to_type_cast, str):
+                content_to_type_cast = content_to_type_cast.encode()
+            elif content_to_type_cast is None:
+                content_to_type_cast = b""
+            elif type(content_to_type_cast) in (int, float):
+                content_to_type_cast = str(content_to_type_cast).encode()
+            elif type(content_to_type_cast) 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 {type(content_to_type_cast)} 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 = []
+        # Type cast content_to_type_cast to type_cast
+        if type_cast is bytes:
+            return content_to_type_cast
+        if type_cast is None:
+            return None
+        if type_cast in (str, int, float):
+            # Handle no data
+            if not content_to_type_cast:
+                if type_cast is str:
+                    return ""
+                return type_cast(0)  # int or float
 
-        # 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)")
+            try:
+                ret = type_cast(content_to_type_cast.decode())
+            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
+
+            return ret
+        elif type_cast in (list, dict):
+            # Handle no data
+            if not content_to_type_cast:
+                return {} if type_cast is dict else []
 
-        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)")
+            result = json.loads(content_to_type_cast.decode())
+
+            if not isinstance(result, type_cast):
+                # json.loads works with dicts and lists, so the result could be ambiguous
+                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
+
+
+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: If the address is valid
+    :rtype: bool
+
+    :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:
+        ip = 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:
@@ -283,15 +445,55 @@ def get_local_ip(all_ips: bool = False) -> str:
         format "ip:port"
     :rtype: str
     """
+
     if not all_ips:
         return socket.gethostbyname(socket.gethostname())
-    else:
-        return socket.gethostbyname_ex(socket.gethostname())[-1]
+    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
@@ -299,121 +501,52 @@ def input_server_config(
 
     :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: "
     :type port_prompt: str, optional
     :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
 
     :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
     :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 (ip, port), name, group
 
 
 def ipstr_to_tup(formatted_ip: str) -> tuple[str, int]:
@@ -421,30 +554,46 @@ def ipstr_to_tup(formatted_ip: str) -> tuple[str, int]:
     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"
     :type formatted_ip: str
-
     :return: A tuple, with 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 tuple(recon_ip_split)  # Convert list to tuple
+
+    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``
+    This function is like 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)
     :type formatted_tuple: tuple[str, int]
-
     :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.py b/setup.py
index 627b3e6..1c43309 100644
--- a/setup.py
+++ b/setup.py
@@ -1,5 +1,4 @@
 import pathlib
-import os
 import re
 from setuptools import setup
 
@@ -10,25 +9,26 @@
 
 CONSTANT_TXT = (ROOT / "hisock/constants.py").read_text()
 
-VERSION = re.search("__version__ = \"[^\n\"]+\"", CONSTANT_TXT).group()
-VERSION = VERSION.lstrip("__version__ = \"").rstrip("\"")
+VERSION = re.search('__version__ = "[^\n"]+"', CONSTANT_TXT).group()
+VERSION = VERSION.lstrip('__version__ = "').rstrip('"')
+ESCAPED_VERSION = re.escape(VERSION)
 
 
 class ANSIColors:
-    PINK = '\033[95m'
-    PURPLE = '\033[35m'
-    BLUE = '\033[94m'
-    CYAN = '\033[96m'
-    GREEN = '\033[92m'
-    DARK_GREEN = '\033[32m'
-    YELLOW = '\033[93m'
-    DARK_YELLOW = '\033[33m'
-    RED = '\033[91m'
-    DARK_RED = '\033[31m'
-    END = '\033[0m'
-
-    FONTCHANGE_BOLD = '\033[1m'
-    FONTCHANGE_UNDERLINE = '\033[4m'
+    PINK = "\033[95m"
+    PURPLE = "\033[35m"
+    BLUE = "\033[94m"
+    CYAN = "\033[96m"
+    GREEN = "\033[92m"
+    DARK_GREEN = "\033[32m"
+    YELLOW = "\033[93m"
+    DARK_YELLOW = "\033[33m"
+    RED = "\033[91m"
+    DARK_RED = "\033[31m"
+    END = "\033[0m"
+
+    FONTCHANGE_BOLD = "\033[1m"
+    FONTCHANGE_UNDERLINE = "\033[4m"
 
 
 def color_text(txt, color=None, font_changes=None):
@@ -36,26 +36,95 @@ 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]])}'}"""
 
 
-print(color_text(f"Building version {VERSION}...", "green"))
+#########################
+#      START BUILD      #
+#########################
+
+print(color_text(f"Building version {VERSION}...\n", "green"))
+
+#########################
+#   EXAMPLE SUBMODULES  #
+#########################
 
 raw_ex_subdirs = [
     content.relative_to(ROOT) for content in (ROOT / "examples").iterdir()
 ]
 stringified_ex_subdirs = list(
-    map(
-        str, list(filter(pathlib.Path.is_dir, raw_ex_subdirs))
-    )
+    map(str, list(filter(pathlib.Path.is_dir, raw_ex_subdirs)))
 )
 ex_subdirs = [
-    subdir.replace('\\', '.').replace('/', '.') for subdir in stringified_ex_subdirs
+    subdir.replace("\\", ".").replace("/", ".") for subdir in stringified_ex_subdirs \
 ]
+ex_subdirs = [i for i in ex_subdirs if i != "examples.__pycache__"]
 
 print_ex_subdirs = "- " + "\n- ".join(ex_subdirs)
 print(
-    color_text("[INFO] Collected the following examples modules:\n"
-               f"{print_ex_subdirs}", "green")
+    color_text(
+        "[INFO] Collected the following examples modules:\n"
+        f"{print_ex_subdirs}\n",
+        "green",
+    )
 )
 
+#########################
+#      REQUIREMENTS     #
+#########################
+
+requirements = [line.strip() for line in (ROOT / "requirements.txt").read_text().splitlines()]
+print_requirements = "- " + "\n- ".join(requirements)
+
+print(
+    color_text(
+        "[INFO] Requirements necessary:\n"
+        f"{print_requirements}\n",
+        "green",
+    )
+)
+
+#########################
+#  REMOVE OLD VERSIONS  #
+#########################
+
+dist_subdir = (ROOT / "dist")
+try:
+    dist_subdir_items = list(dist_subdir.iterdir())
+except FileNotFoundError:
+    dist_subdir_items = []
+    print(color_text("[INFO] dist doesn't exist!\n", "green"))
+
+if dist_subdir.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"))
+        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)
+            ):
+                print(
+                    color_text(
+                        f"[WARNING] Found file {old_build_files.name} with same version!\n"
+                        "          "
+                        "If you already pushed this file to PyPI, do NOT push the files being "
+                        "generated right now.",
+                        "yellow"
+                    )
+                )
+            else:
+                print(
+                    color_text(
+                        f"[INFO] Removing build file {old_build_files.name}...",
+                        "green"
+                    )
+                )
+            old_build_files.unlink()
+    print()
+
+#########################
+#         SETUP         #
+#########################
+
 setup(
     name=NAME,
     version=VERSION,
@@ -73,24 +142,20 @@ def color_text(txt, color=None, font_changes=None):
         "Operating System :: POSIX :: Linux",
         "Programming Language :: Python :: 3.7",
         "Programming Language :: Python :: 3.8",
-        "Programming Language :: Python :: 3.9"
-    ],
-    install_requires=[
-        "pytest>=6.2.5",
-        "pycryptodome>=3.11"
+        "Programming Language :: Python :: 3.9",
     ],
+    install_requires=requirements,
     packages=[
-        'hisock',
-        'examples',
-    ] + ex_subdirs,
-    python_requires=">=3.7"
+                 "hisock",
+                 "examples",
+             ]
+             + ex_subdirs,
+    python_requires=">=3.7",
 )
 
-if os.path.exists("dist"):
-    for i in os.listdir("dist"):
-        if not (
-                re.match(f"{NAME}-{VERSION}\.tar\.gz", i) or
-                re.match(f"{NAME}-{VERSION}-py3-none-any\.whl", i)
-        ):
-            print(color_text(f"Removing old version {i}...", "red"))
-            os.remove(os.path.join("dist", i))
+print(
+    color_text(
+        f"\nSuccessfully built {NAME} {VERSION}!",
+        "green"
+    )
+)


Test patch:
diff --git a/tests/test_decorator.py b/tests/test_decorator.py
index e6555aa..60f6504 100644
--- a/tests/test_decorator.py
+++ b/tests/test_decorator.py
@@ -14,19 +14,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
@@ -37,11 +43,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)
 
 
 #################################################
@@ -82,63 +88,58 @@ def func_client_typecast(msg: int):
 
 class TestServerDecs:
     def test_server_no_typehint(self):
-        assert server_dummy.funcs['e'] == {
+        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,
         }
 
     def test_server_two_typecast(self):
-        assert server_dummy.funcs['f'] == {
+        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,
         }
 
     def test_server_one_typecast(self):
-        assert server_dummy.funcs['g'] == {
+        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,
         }
 
     def test_server_clt_typecast(self):
-        assert server_dummy.funcs['h'] == {
+        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,
         }
 
 
 class TestClientDecs:
     def test_client_no_typecast(self):
-        assert client_dummy.funcs['i'] == {
+        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,
         }
 
     def test_client_typecast(self):
-        assert client_dummy.funcs['j'] == {
+        assert client_dummy.funcs["j"] == {
             "func": func_client_typecast,
             "name": func_client_typecast.__name__,
-            "type_hint": int
+            "type_hint": {"message": int},
+            "threaded": False,
         }
 
     def test_client_exception(self):
         with pytest.raises(ValueError):
+
             @client_dec("$RESERVED$")
             def func_client_exception(msg):
                 pass
diff --git a/tests/test_get_client/client.py b/tests/test_get_client/client.py
new file mode 100644
index 0000000..5d80004
--- /dev/null
+++ b/tests/test_get_client/client.py
@@ -0,0 +1,21 @@
+from hisock import connect
+from hisock.utils import _input_port, iptup_to_str
+
+client = connect(("127.0.0.1", _input_port("Port: ")), input("Name: "))
+
+
+@client.on("client_connect")
+def on_client_connect(client_data: dict):
+    if client_data["name"] == client.name:
+        return
+
+    client_data["ip"] = iptup_to_str(client_data["ip"])
+
+    # Get the client
+    for client_identifier in (client_data["ip"], client_data["name"]):
+        other_client = client.get_client(client_identifier)
+        print(f"{client_identifier=} {other_client=}")
+
+
+while True:
+    client.update()
diff --git a/tests/test_get_client/server.py b/tests/test_get_client/server.py
new file mode 100644
index 0000000..57eee12
--- /dev/null
+++ b/tests/test_get_client/server.py
@@ -0,0 +1,9 @@
+# Dummy server
+
+from hisock import start_server
+from hisock.utils import _input_port
+
+server = start_server(("127.0.0.1", _input_port("Port: ")))
+
+while True:
+    server.run()
diff --git a/tests/test_typecast.py b/tests/test_typecast.py
index 00f4c06..48355d1 100644
--- a/tests/test_typecast.py
+++ b/tests/test_typecast.py
@@ -6,86 +6,72 @@
 
 import pytest
 
-from hisock.utils import _type_cast_server, InvalidTypeCast
+from hisock.utils import _type_cast, InvalidTypeCast
 
 
 class TestServerTypeCast:
-    dummy_func = {
-        "name": "random_function"
-    }
+    func_name = "<test_func>"
 
     def test_type_cast_str(self):
-        _str = _type_cast_server(
-            str, b"Why hello there, fello human!",
-            self.__class__.dummy_func
+        _str = _type_cast(
+            str, b"Why hello there, fello human!", self.__class__.func_name
         )
 
         assert _str == "Why hello there, fello human!"
 
     def test_type_cast_str_raise(self):
-        with pytest.raises(TypeError):
-            _type_cast_server(
-                str, b"\xff\xfea\x00",  # Encoded in utf-16, so...
-                self.__class__.dummy_func
+        with pytest.raises(InvalidTypeCast):
+            _type_cast(
+                str,
+                b"\xff\xfea\x00",  # Encoded in utf-16, so...
+                self.__class__.func_name,
             )
 
     def test_type_cast_int(self):
-        _int = _type_cast_server(
-            int, b"6969696969696969",
-            self.__class__.dummy_func
-        )
+        _int = _type_cast(int, b"6969696969696969", self.__class__.func_name)
 
         assert _int == 6969696969696969
 
     def test_type_cast_int_raise(self):
-        with pytest.raises(TypeError):
-            _type_cast_server(
-                int, b"This is not int, why???",
-                self.__class__.dummy_func
+        with pytest.raises(InvalidTypeCast):
+            _type_cast(
+                int, b"This is not int, why???", self.__class__.func_name
             )
 
     def test_type_cast_float(self):
-        _float = _type_cast_server(
-            float, b"696969.696969",
-            self.__class__.dummy_func
-        )
+        _float = _type_cast(float, b"696969.696969", self.__class__.func_name)
 
         assert _float == 696969.696969
 
     def test_type_cast_float_raise(self):
-        with pytest.raises(TypeError):
-            _type_cast_server(
-                float, b"This is not float, why???",
-                self.__class__.dummy_func
+        with pytest.raises(InvalidTypeCast):
+            _type_cast(
+                float, b"This is not float, why???", self.__class__.func_name
             )
 
     def test_type_cast_dict_list(self):
-        _dict = _type_cast_server(
-            dict, b'{"I like": "cheese"}',
-            self.__class__.dummy_func
+        _dict = _type_cast(
+            dict, b'{"I like": "cheese"}', self.__class__.func_name
         )
-        _list = _type_cast_server(
-            list, b'["Do", "you", "also", "like", "cheese?"]',
-            self.__class__.dummy_func
+        _list = _type_cast(
+            list, b'["Do", "you", "also", "like", "cheese?"]', self.__class__.func_name
         )
 
         assert _dict == {"I like": "cheese"}
         assert _list == ["Do", "you", "also", "like", "cheese?"]
 
     def test_type_cast_dict_list_raise(self):
-        with pytest.raises(TypeError):
-            _type_cast_server(
-                dict, b"Lolololol",
-            )
-        with pytest.raises(TypeError):
-            _type_cast_server(
-                list, b"Lolololol"
+        with pytest.raises(InvalidTypeCast):
+            _type_cast(
+                dict,
+                b"Lolololol",
+                self.__class__.func_name
             )
+        with pytest.raises(InvalidTypeCast):
+            _type_cast(list, b"Lolololol", self.__class__.func_name)
 
     def test_type_cast_invalid_cast(self):
-        invalid = _type_cast_server(
-            bytearray, b"haha this will not do anything",
-            self.__class__.dummy_func
-        )
-
-        assert invalid is None
+        with pytest.raises(InvalidTypeCast):
+            _type_cast(
+                bytearray, b"haha this will not do anything", self.__class__.func_name
+            )