diff --git a/.api-template/core/ApiConfig.handlebars b/.api-template/core/ApiConfig.handlebars
new file mode 100644
index 0000000..483f5d1
--- /dev/null
+++ b/.api-template/core/ApiConfig.handlebars
@@ -0,0 +1,169 @@
+extends {{>partials/api_config_parent_class}}
+class_name {{>partials/api_config_class_name}}
+
+{{>partials/disclaimer_autogenerated}}
+
+# Configuration options for Api endpoints
+# =======================================
+#
+# Helps share configuration customizations across Apis:
+# - host, port & scheme
+# - extra headers (low priority, high priority)
+# - transport layer security options (TLS certificates)
+# - log level
+#
+# You probably want to make an instance of this class with your own values,
+# and feed it to each Api's constructor, before calling the Api's methods.
+#
+# Since it is a Resource, you may use `ResourceSaver.save()` and `preload()`
+# to save it and load it from file, for convenience.
+#
+
+
+# These are constant, immutable default values.  Best not edit them.
+# To set different values at runtime, use the @export'ed properties below.
+const BEE_DEFAULT_HOST := "{{#if host}}{{{host}}}{{else}}localhost{{/if}}"
+const BEE_DEFAULT_PORT_HTTP := 80
+const BEE_DEFAULT_PORT_HTTPS := 443
+const BEE_DEFAULT_POLLING_INTERVAL_MS := 333  # milliseconds
+
+
+# Configuration also handles logging because it's convenient.
+enum LogLevel {
+	SILENT,
+	ERROR,
+	WARNING,
+	INFO,
+	DEBUG,
+}
+
+
+## Log level to configure verbosity.
+@export var log_level := LogLevel.WARNING
+
+{{!--
+# Not sure if this should hold the HTTPClient instance or not.  Not for now.
+# Godot recommends using a single client for all requests, so it perhaps should.
+
+# Godot's HTTP Client we are using.
+# If none was set (by you), we'll lazily make one.
+var bee_client: HTTPClient:
+	set(value):
+		bee_client = value
+	get:
+		if not bee_client:
+			bee_client = HTTPClient.new()
+		return bee_client
+--}}
+
+## The host to connect to, with or without the protocol scheme.
+## We toggle TLS accordingly to the provided scheme, if any.
+@export var host := BEE_DEFAULT_HOST:
+	set(value):
+		if value.begins_with("https://"):
+			tls_enabled = true
+			value = value.substr(8)  # "https://".length() == 8
+		elif value.begins_with("http://"):
+			tls_enabled = false
+			value = value.substr(7)  # "http://".length() == 7
+		host = value
+
+
+## Port through which the connection will be established.
+## NOTE: changing the host may change the port as well if the scheme was provided, see above.
+@export var port := BEE_DEFAULT_PORT_HTTP
+
+
+## Headers used as base for all requests made by Api instances using this config.
+## Those are the lowest priority headers, and are merged with custom headers provided in the bee_request() method call
+## as well as the headers override below, to compute the final, actually sent headers.
+@export var headers_base := {
+	# Stigmergy: everyone does what is left to do (like ants do)
+	"User-Agent": "Stigmergiac/1.0 (Godot)",
+	# For my mental health's sake, only JSON is supported for now
+	"Accept": "application/json",
+	"Content-Type": "application/json",
+}
+
+
+## High-priority headers, they will always override other headers coming from the base above or the method call.
+@export var headers_override := {}
+
+
+## Duration of sleep between poll() calls.
+@export var polling_interval_ms := BEE_DEFAULT_POLLING_INTERVAL_MS  # milliseconds
+
+
+## Enable the Transport Security Layer (packet encryption, HTTPS)
+@export var tls_enabled := false:
+	set(value):
+		tls_enabled = value
+		# port = BEE_DEFAULT_PORT_HTTPS if tls_enabled else BEE_DEFAULT_PORT_HTTP
+
+
+## You should preload your *.crt file (the whole chain) in here if you want TLS.
+## I usually concatenate my /etc/ssl/certs/ca-certificates.crt and webserver certs here.
+## Remember to add the *.crt file to the exports, if necessary.
+@export var trusted_chain: X509Certificate  # only used if tls is enabled
+@export var common_name_override := ""  # for TLSOptions
+
+
+## Dynamic accessor using the TLS properties above, but you may inject your own
+## for example if you need to use TLSOptions.client_unsafe.  Best not @export this.
+var tls_options: TLSOptions:
+	set(value):
+		tls_options = value
+	get:
+		if not tls_enabled:
+			return null
+		if not tls_options:
+			tls_options = TLSOptions.client(trusted_chain, common_name_override)
+		return tls_options
+
+
+func log_error(message: String):
+	if self.log_level >= LogLevel.ERROR:
+		push_error(message)
+
+func log_warning(message: String):
+	if self.log_level >= LogLevel.WARNING:
+		push_warning(message)
+
+func log_info(message: String):
+	if self.log_level >= LogLevel.INFO:
+		print(message)
+
+func log_debug(message: String):
+	if self.log_level >= LogLevel.DEBUG:
+		print(message)
+
+
+{{#each authMethods}}
+# Authentication method `{{name}}`.
+{{#if isBasicBearer }}
+# Basic Bearer Authentication `{{bearerFormat}}`
+func set_security_{{name}}(value: String):
+	self.headers_base["Authorization"] = "Bearer %s" % value
+
+
+{{else if isApiKey }}
+# Api Key Authentication `{{keyParamName}}`
+func set_security_{{name}}(value: String):
+	{{#if isKeyInHeader }}
+	self.headers_base["{{keyParamName}}"] = value
+	{{else if isKeyInQuery }}
+	# Implementing this should be straightforward
+	log_error("Api Key in Query is not supported at the moment.  (contribs welcome)")
+	{{else if isKeyInCookie }}
+	log_error("Api Key in Cookie is not supported at the moment.  (contribs welcome)")
+	{{else }}
+	log_error("Unrecognized Api Key format (contribs welcome).")
+	{{/if}}
+
+
+{{else}}
+# → Skipped: not implemented in the gdscript templates.  (contribs are welcome)
+
+
+{{/if}}
+{{/each}}
diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml
index 57917e1..1cdb11a 100755
--- a/.forgejo/workflows/build.yml
+++ b/.forgejo/workflows/build.yml
@@ -7,8 +7,25 @@ on:
       - 'v*'
 
 jobs:
+  generate:
+    runs-on: stable
+    container:
+      image: git.euph.dev/actions/runner-java-21:1300
+    steps:
+      - name: "Checkout"
+        uses: "https://git.euph.dev/actions/checkout@v3"
+      - name: "Generate"
+        run: just generate
+      - name: Upload API Client as Artifact
+        uses: "https://git.euph.dev/actions/upload-artifact@v3"
+        with:
+          name: api-client
+          path: scripts/api-client/*
+
   build:
     runs-on: stable
+    needs:
+      - generate
     container:
       image: git.euph.dev/actions/runner-redot-4.3:latest
     strategy:
@@ -35,6 +52,11 @@ jobs:
     steps:
       - name: "Checkout"
         uses: "https://git.euph.dev/actions/checkout@v3"
+      - name: Download artifact from previous job
+        uses: "https://git.euph.dev/actions/download-artifact@v3"
+        with:
+          name: api-client
+          path: scripts/api-client
       - name: Build Binary
         run: |
           if [ "${{ matrix.build-dir }}" != "" ]; then
@@ -104,9 +126,9 @@ jobs:
             # Tower Defence - Client ${{ github.ref_name }}
             Run this release with docker like this:
             \`\`\`sh
-            docker run --rm -p 8080:80 git.euph.dev/towerdefence/web-client:${{ github.ref_name }}
+            docker run --rm -p 8100:80 git.euph.dev/towerdefence/web-client:${{ github.ref_name }}
             \`\`\`
-            It will be available under [\`localhost:8080\`](localhost:8080)
+            It will be available under [\`localhost:8100\`](localhost:8100)
             <br><br>
             For more information read the [Documentation](https://git.euph.dev/TowerDefence/Dokumentation/wiki/Client/Config)
 
diff --git a/.gitignore b/.gitignore
index e2dce33..042fdcd 100755
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
 # Redot 4+ specific ignores
 .godot/
 /android/
+openapitools.json
+scripts/api-client/
diff --git a/Justfile b/Justfile
new file mode 100644
index 0000000..2ccbb91
--- /dev/null
+++ b/Justfile
@@ -0,0 +1,20 @@
+_choose:
+    just --choose
+
+lint:
+    -gdlint .
+    -gdformat -d .
+
+api_version := "v0.0.0-rc.4"
+api_location := "scripts/api-client"
+generate:
+    #!/bin/sh
+    API_URL="https://git.euph.dev/TowerDefence/Server/releases/download/{{ api_version }}/api.yml"
+    rm -rf {{ api_location }}
+    mkdir -p {{ api_location }}
+    npx --yes @openapitools/openapi-generator-cli \
+      generate \
+      -g gdscript \
+      -i "$API_URL" \
+      -o {{ api_location }} \
+      -t .api-template
diff --git a/config/api_config.tres b/config/api_config.tres
new file mode 100644
index 0000000..318c29c
--- /dev/null
+++ b/config/api_config.tres
@@ -0,0 +1,18 @@
+[gd_resource type="Resource" script_class="ApiConfig" load_steps=2 format=3 uid="uid://cdixdbu3sqgjn"]
+
+[ext_resource type="Script" path="res://scripts/api-client/core/ApiConfig.gd" id="1_0p6qs"]
+
+[resource]
+script = ExtResource("1_0p6qs")
+log_level = 2
+host = "localhost"
+port = 8080
+headers_base = {
+"Accept": "application/json",
+"Content-Type": "application/json",
+"User-Agent": "Stigmergiac/1.0 (Godot)"
+}
+headers_override = {}
+polling_interval_ms = 333
+tls_enabled = false
+common_name_override = ""
diff --git a/gdformatrc b/gdformatrc
index 235fd70..cadddef 100644
--- a/gdformatrc
+++ b/gdformatrc
@@ -1,4 +1,5 @@
 excluded_directories: !!set
   .git: null
   addons: null
+  api-client: null
 
diff --git a/gdlintrc b/gdlintrc
index a140f15..2dfe0ce 100644
--- a/gdlintrc
+++ b/gdlintrc
@@ -25,6 +25,7 @@ enum-name: ([A-Z][a-z0-9]*)+
 excluded_directories: !!set
   .git: null
   addons: null
+  api-client: null
 expression-not-assigned: null
 function-argument-name: _?[a-z][a-z0-9]*(_[a-z0-9]+)*
 function-arguments-number: 10
diff --git a/scenes/login.tscn b/scenes/login.tscn
index 35c7d0d..88fe867 100644
--- a/scenes/login.tscn
+++ b/scenes/login.tscn
@@ -1,6 +1,7 @@
-[gd_scene load_steps=3 format=3 uid="uid://dtaaw31x3n22f"]
+[gd_scene load_steps=4 format=3 uid="uid://dtaaw31x3n22f"]
 
-[ext_resource type="Script" path="res://scripts/login/login.gd" id="1_12w35"]
+[ext_resource type="Script" path="res://scripts/ui/login.gd" id="1_12w35"]
+[ext_resource type="Resource" uid="uid://cdixdbu3sqgjn" path="res://config/api_config.tres" id="2_60hb8"]
 
 [sub_resource type="StyleBoxFlat" id="StyleBoxFlat_d0bbp"]
 
@@ -74,12 +75,12 @@ scroll_active = false
 layout_mode = 2
 secret = true
 
-[node name="Button" type="Button" parent="Panel/VBoxContainer" node_paths=PackedStringArray("username_field", "password_field", "http_request")]
+[node name="Button" type="Button" parent="Panel/VBoxContainer" node_paths=PackedStringArray("username_field", "password_field")]
 layout_mode = 2
 text = "Login"
 script = ExtResource("1_12w35")
 username_field = NodePath("../InputContainer/UsernameContainer/UsernameInput")
 password_field = NodePath("../InputContainer/PasswordContainer/PasswordInput")
-http_request = NodePath("HTTPRequest")
+api_config = ExtResource("2_60hb8")
 
 [node name="HTTPRequest" type="HTTPRequest" parent="Panel/VBoxContainer/Button"]
diff --git a/scripts/login/login.gd b/scripts/login/login.gd
deleted file mode 100644
index 8d053e1..0000000
--- a/scripts/login/login.gd
+++ /dev/null
@@ -1,39 +0,0 @@
-extends Button
-
-@export var username_field: LineEdit
-@export var password_field: LineEdit
-@export var http_request: HTTPRequest
-var url: String = "http://localhost:8080/api/v1/player/login"
-var headers = ["Content-Type: application/json"]
-
-
-func _ready() -> void:
-	if not username_field:
-		push_error("No Username Field set")
-		return
-	if not password_field:
-		push_error("No Password Field set")
-		return
-	if not http_request:
-		push_error("No Http Request set")
-		return
-	connect("pressed", login)
-
-
-func login() -> void:
-	var login_data = {
-		"username": username_field.text,
-		"password": password_field.text,
-	}
-	var json = JSON.stringify(login_data)
-	http_request.connect("request_completed", on_login_response)
-	http_request.request(url, headers, HTTPClient.METHOD_POST, json)
-
-
-func on_login_response(
-	_result: int,
-	_response_code: int,
-	_headers: PackedStringArray,
-	body: PackedByteArray,
-) -> void:
-	print(body.get_string_from_utf8())
diff --git a/scripts/ui/login.gd b/scripts/ui/login.gd
new file mode 100644
index 0000000..c99e58b
--- /dev/null
+++ b/scripts/ui/login.gd
@@ -0,0 +1,37 @@
+extends Button
+
+@export var username_field: LineEdit
+@export var password_field: LineEdit
+@export var api_config: ApiConfig
+var api: ServerApi
+
+
+func _ready() -> void:
+	if not username_field:
+		push_error("No Username Field set")
+		return
+	if not password_field:
+		push_error("No Password Field set")
+		return
+	if not api_config:
+		push_error("No API Configuration provided")
+		return
+	api = ServerApi.new(api_config)
+	connect("pressed", login)
+	username_field.connect("pressed", login)
+	password_field.connect("pressed", login)
+
+
+func login() -> void:
+	var login_data = PlayerLoginData.new()
+	login_data.username = username_field.text
+	login_data.password = password_field.text
+	api.player_login(login_data, on_success, on_error)
+
+
+func on_success(a: ApiResponse) -> void:
+	print(a.body)
+
+
+func on_error(b: ApiError) -> void:
+	print(b.response_code)