switch backend to clojure

This commit is contained in:
Travis Shears 2026-04-16 12:04:23 +02:00
parent 99f73315b3
commit 09bf48481a
Signed by: travisshears
GPG key ID: CB9BF1910F3F7469
11 changed files with 228 additions and 159 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,44 +0,0 @@
# Build stage
FROM golang:1.25-bookworm AS builder
WORKDIR /app
# Install dependencies including libenet-dev from Debian repos
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
gcc \
pkg-config \
libenet-dev \
sqlite3 \
libsqlite3-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy go mod and sum files
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
COPY ./*.go .
# Enable CGO for go-sqlite3 and go-enet
ENV CGO_ENABLED=1
RUN go build -o main .
# Runtime stage
FROM debian:bookworm-slim
WORKDIR /app
# Install runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
libenet7 \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/main .
EXPOSE 8095
CMD ["./main"]

23
backend/deps.edn Normal file
View file

@ -0,0 +1,23 @@
{:paths ["src"]
:deps { ;; db
com.datomic/local {:mvn/version "1.0.291"}
;; logging
com.taoensso/telemere {:mvn/version "1.0.0"}
;; environment variables
environ/environ {:mvn/version "1.2.0"}
;; json / schema validation
metosin/malli {:mvn/version "0.18.0"}
metosin/muuntaja {:mvn/version "0.6.11"}
;; networking - UDP/TCP/HTTP streaming
aleph/aleph {:mvn/version "0.9.7"}
org.clj-commons/byte-streams {:mvn/version "0.3.4"}
org.clojure/clojure {:mvn/version "1.12.1"}}
:aliases
{;; Run with clj -T:build function-in-build
:build {:deps {io.github.clojure/tools.build {:git/tag "v0.10.9" :git/sha "e405aac"}}
:ns-default build}}}

10
backend/dev.sh Executable file
View file

@ -0,0 +1,10 @@
#!/bin/sh
# Watch for changes to .clj files and auto-restart the server
# fd -e clj: Find all files ending in .clj
# entr -r: Run the command when files change, -r flag restarts the command on re-trigger
# clojure: Clojure CLI
# -M: Use the main alias from deps.edn (if defined)
# -m game-server.main: Run the -main function from game-server.main namespace
fd -e clj | entr -r clojure -M -m game-server.main

View file

@ -1,8 +0,0 @@
services:
backend:
build:
context: .
dockerfile: Dockerfile
container_name: love2d-backend-dev
ports:
- "8095:8095"

View file

@ -1,16 +0,0 @@
module love2d_backend
go 1.25.0
require (
github.com/codecat/go-libs v0.0.0-20210906174629-ffa6674c8e05
github.com/mattn/go-sqlite3 v1.14.32
)
require (
github.com/codecat/go-enet v0.0.0-20250728072647-ae229138f138
github.com/fatih/color v1.19.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
golang.org/x/sys v0.42.0 // indirect
)

View file

@ -1,15 +0,0 @@
github.com/codecat/go-enet v0.0.0-20250728072647-ae229138f138 h1:qQaqly9oVi84i4nLWmM551z7kcFZIy5koCLoPwCarGY=
github.com/codecat/go-enet v0.0.0-20250728072647-ae229138f138/go.mod h1:sXkhNvv8T1d/aPEwipUzk7rGWwGJ3uJST2P/Z0zE0L4=
github.com/codecat/go-libs v0.0.0-20210906174629-ffa6674c8e05 h1:JSfDXHJvrIpQ8Agy//yoIlGpfIprTCDUytmf68fd/Lc=
github.com/codecat/go-libs v0.0.0-20210906174629-ffa6674c8e05/go.mod h1:xJW98cHEb+Kbuu0qmoKzExh3blthZqojIYOFo27VgvE=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=

View file

@ -1,76 +0,0 @@
package main
import (
_ "embed"
"log/slog"
"os"
enet "github.com/codecat/go-enet"
_ "github.com/mattn/go-sqlite3"
)
func main() {
port := uint16(8095)
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
// Initialize enet
slog.Info("Initializing E-Net Server", "port", port)
enet.Initialize()
// Create a host listening on 0.0.0.0:8095
host, err := enet.NewHost(enet.NewListenAddress(port), 32, 1, 0, 0)
if err != nil {
slog.Error("Couldn't create host", "error", err)
return
}
slog.Info("Server started", "port", port)
// The event loop
for true {
// Wait until the next event
ev := host.Service(1000)
// Do nothing if we didn't get any event
if ev.GetType() == enet.EventNone {
continue
}
switch ev.GetType() {
case enet.EventConnect: // A new peer has connected
slog.Info("New peer connected", "address", ev.GetPeer().GetAddress())
case enet.EventDisconnect: // A connected peer has disconnected
slog.Info("Peer disconnected", "address", ev.GetPeer().GetAddress())
case enet.EventReceive: // A peer sent us some data
// Get the packet
packet := ev.GetPacket()
// We must destroy the packet when we're done with it
defer packet.Destroy()
// Get the bytes in the packet
packetBytes := packet.GetData()
// Respond "pong" to "ping"
if string(packetBytes) == "ping" {
ev.GetPeer().SendString("pong", ev.GetChannelID(), enet.PacketFlagReliable)
continue
}
// Disconnect the peer if they say "bye"
if string(packetBytes) == "bye" {
slog.Info("Peer sent bye, disconnecting")
ev.GetPeer().Disconnect(0)
continue
}
}
}
// Destroy the host when we're done with it
host.Destroy()
// Uninitialize enet
enet.Deinitialize()
}

View file

@ -0,0 +1,145 @@
(ns game-server.main
(:require
[aleph.udp :as udp]
[manifold.stream :as s]
[clj-commons.byte-streams :as bs]
[taoensso.telemere :as t]
[clojure.pprint :refer [pprint]]
[clojure.string :as str])
(:import (java.security MessageDigest))
(:gen-class))
;; UDP examples
;; - https://gist.github.com/crosstyan/8a87ebdb0c23e549b1c75e9e4013ffa5
(def port 9999)
(def players (atom {})) ; client registry: {player-id {:host ... :port ...}}
(def games (atom {:by-id {} :all []}))
;; Global socket reference for sending
(def server-socket (atom nil))
;; (defn parse-position-msg
;; "Parse: 'player-id:x:y' format"
;; [msg-str]
;; (try
;; (let [[player-id x y] (str/split msg-str #":")]
;; {:player-id (Integer/parseInt player-id)
;; :x (Double/parseDouble x)
;; :y (Double/parseDouble y)})
;; (catch Exception e
;; (t/log! {:level :warn :data {:msg msg-str :error (str e)}} "Failed to parse message")
;; nil)))
;;
;; (if waiting-game
;; (swap! games (fn [state]
;; (let )
;; :by-id (assoc (:by-id state) player-id {:player-1 }
;; (-> (:by-id state)
;; (assoc player-id waiting-game))
;; :waiting (disj (:waiting state) waiting-game)
;; :all (conj (:all state) waiting-game)
;; })))
;; (defn get-game-by-player-id [player-id]
;; (first (filter (fn [game] (or (= (:player-1 game) player-id) (= (:player-2 game) player-id))) @games)))
;; (defn get-waiting-game []
;; (first (filter (fn [game] (nil? (:player-2 game))) @games)))
;; (defn join-waiting-game! [player-id other-player-id]
;; (if-let [waiting-game (get-game-by-player-id other-player-id)]
;; (swap! games update-in [(.indexOf @games waiting-game)] assoc :player-2 player-id)
;; (swap! games conj {:player-1 player-id :player-2 nil})))
(defn md5 [s]
(let [md (MessageDigest/getInstance "MD5")
digest (.digest md (.getBytes s))]
(apply str (map #(format "%02x" %) digest))))
(defn send-to-player
"Send a UDP packet to a player"
[player-id message]
(if-let [player (get @players player-id)]
(try
(s/put! @server-socket
{:host (:host player)
:port (:port player)
:message message})
(catch Exception e
(t/log! {:level :warn :data {:player-id player-id :error (str e)}} "Failed to send to player")))
(t/log! {:level :warn :data {:player-id player-id}} "Player not found")))
(defn join-game! [player-id]
(let [waiting-game
(some-> (first (filter (fn [game] (nil? (:player-2 game))) (@games :all)))
(assoc :player-2 player-id))
other-player-id (:player-1 waiting-game)
new-game {:player-1 player-id :player-2 nil}]
(if waiting-game
(do
(swap! games (fn [state]
{:by-id
(-> (:by-id state)
(assoc player-id waiting-game)
(assoc other-player-id waiting-game))
:all (map (fn [game] (if (= (:player-1 game) other-player-id)
waiting-game
game)) (:all state))}))
(send-to-player other-player-id "READY_TO_PLAY")
(send-to-player player-id "READY_TO_PLAY"))
(do
(swap! games (fn [state]
{:by-id (assoc (:by-id state) player-id new-game)
:all (conj (:all state) new-game)}))
(send-to-player player-id "WAIT")))))
(defn register-player! [host port]
(let [player-id (md5 (str host port))]
(swap! players assoc player-id {:host host :port port})
(t/log! {:level :info :data {:player-id player-id}} "Player registered")
(join-game! player-id)
(pprint {:games @games :players @players})))
;; (defn broadcast-to-others [from-player-id position-data]
;; "Send position to all other players"
;; (let [other-players (dissoc @players from-player-id)
;; msg (str from-player-id ":" (:x position-data) ":" (:y position-data))]
;; (doseq [[pid _] other-players]
;; (send-to-player pid msg))))
(defn parse-packet [packet]
(let [host (-> packet (:sender) (bean) (:address) (bean) (:hostAddress))
port (-> packet (:sender) (bean) (:port))
vector-msg (-> packet (:message) (vec))
string-msg (bs/to-string (:message packet))
msg-parts (str/split string-msg #"#")]
{:host host
:port port
:msg (:message packet)
:msg-vec vector-msg
:msg-string string-msg
:msg-parts msg-parts}))
(defn packet-consumer [packet]
(t/log! {:level :info :data {:packet packet}} "Consuming packet")
(case (first (:msg-parts packet))
"REGISTER" (register-player! (:host packet) (:port packet))))
(defn start-server [port]
(let [socket @(udp/socket {:port port})]
(reset! server-socket socket)
(t/log! {:level :info :data {:port port}} "UDP server listening")
(->> socket
(s/map parse-packet)
(s/consume packet-consumer))
socket))
(defn -main []
(t/log! {:level :info :data {:port port}} "Game server starting")
(start-server port)
@(promise)) ;; keep server running

48
backend/test-server.sh Executable file
View file

@ -0,0 +1,48 @@
#!/bin/bash
# Simple UDP test script for the game server
# Sends registration and position updates to simulate two players
HOST="localhost"
PORT=9999
echo "Testing game server at $HOST:$PORT"
echo ""
# Helper to send UDP packet
send_udp() {
local msg="$1"
echo "Sending: $msg"
echo -n "$msg" | nc -u -w1 "$HOST" "$PORT"
}
echo "=== Registering Player 1 ==="
send_udp "REGISTER"
nc -u -l 0.0.0.0 12345
# sleep 0.5
# echo ""
# echo "=== Registering Player 2 ==="
# send_udp "register:2"
# sleep 0.5
# echo ""
# echo "=== Player 1 moving around ==="
# for i in {1..3}; do
# x=$((100 + i * 10))
# y=$((200 + i * 5))
# send_udp "1:$x:$y"
# sleep 0.3
# done
# echo ""
# echo "=== Player 2 moving around ==="
# for i in {1..3}; do
# x=$((300 - i * 10))
# y=$((150 + i * 20))
# send_udp "2:$x:$y"
# sleep 0.3
# done
echo ""
echo "Done!"