Developing a multiplayer third-person shooter game as a solo developer is a journey filled with challenges and rewards. I embarked on this adventure to create Wizard Masters, a web-based multiplayer game where players battle as mages wielding elemental spells.
Built using Clojure, a Lisp dialect, this project pushed the boundaries of web game development and my own skills as a programmer. Here’s how it went.
In Wizard Masters, players can choose from six elemental spells—fire, toxic, ice, lightning, and earth—and compete in two modes: solo and team deathmatch. I published the game on CrazyGames to reach a broader audience. However, its multiplayer nature demanded a large player base, which was a constant challenge.
Game Link: https://wizardmasters.io
Why Clojure?
Clojure is my go-to programming language, both professionally and personally. It’s a full-stack language:
- Clojure runs on the JVM for backend development.
- ClojureScript compiles to JavaScript, enabling smooth browser-based applications.
This one language to rule them all approach made it an obvious choice for me. Additionally, Clojure’s REPL ( Read-Eval-Print Loop) system is a game-changer. Unlike typical REPLs, Clojure’s REPL is highly interactive and organic, allowing live updates to the game without refreshing the browser. This significantly sped up my development process, enabling me to create and test mechanics in real time.
I created a YouTube video showing how to use the REPL with jMonkeyEngine, a Java-based game framework. You can watch it here.
Graphics Library
After experimenting with several graphics libraries, including Three.js and PlayCanvas, I chose Babylon.js. Here’s why:
- Feature-rich: Babylon.js offers a robust set of tools for 3D development.
- Great documentation: Compared to other libraries, Babylon’s documentation stands out for its clarity and comprehensiveness.
- Supportive community: The community’s assistance proved invaluable.
ClojureScript’s npm integration through shadow-cljs made it easy to incorporate Babylon.js, allowing me to focus on building the game.
Code
The following code snippet showcases the implementation of a :player/jump
rule, which handles the player’s ability to
jump in the game. This rule uses a :what
block, essentially a hashmap, to define the conditions under which the rule
is triggered.
In Wizard Masters, all game data resides in a global game database—a single large hashmap. The fields referenced in the :what block (e.g., :pointer-locked?, :player/ground?) are keys in this global hashmap. To execute the rule, several conditions must be met:
- Each key in the
:what
block must have a non-null value. - The
:when
block must evaluate to true. - The
:then
block is executed when the above conditions are satisfied.
The triggering of this rule occurs through the fire-rules function, which activates the rule whenever a :what
field is
updated, regardless of whether the value changes. If a field has a :then false
attribute, the rule will not trigger,
avoiding infinite loops and unnecessary executions. The rule system was influenced by the Clojure library O’Doyle
Rules by Zach Oakes.
Here’s the complete implementation for the :player/jump
rule:
(reg-rule
:player/jump
{:locals {:jump-vec (v3)}
:what {:keys-pressed {}
:player/mobile-jump-click? {}
:pointer-locked? {:then false}
:player/ground? {:then false}
:player/capsule {:then false}
:player/anim-groups {:then false}
:keys-was-pressed {:then false}
:player/jump-up? {:then false}}
:when (fn [{{:keys [player/game-started?
player/mobile-jump-click?
player/ground?
player/dash?
player/jump-up?
player/current-running-anim-group
player/current-health]} :session}]
(and game-started?
(nil? current-running-anim-group)
(or (re/key-is-pressed? "Space") mobile-jump-click?)
ground?
(not (freezing?))
(not (wind-stunned?))
(not jump-up?)
(not dash?)
(> current-health 0)))
:then (fn [{{player-capsule :player/capsule
player-jump-force :player/jump-force} :session
{:keys [jump-vec]} :locals}]
(j/assoc! jump-vec :y player-jump-force)
(when-not (casting-spell?)
(re/fire-rules {:player/jump-up? true}))
(let [player-pos (api.core/get-pos player-capsule)]
(api.physics/apply-impulse player-capsule jump-vec player-pos)))})
How It Works
:what
block defines dependencies, such as key presses and the character’s physical capsule etc.:when
block ensures the jump can only occur under certain conditions (e.g., the player isn’t frozen or stunned).:then
block applies an upward force to the player’s capsule and triggers additional rules if needed.
The next example demonstrates a rule for updating the player’s rotation every frame. This ensures the character always
faces the forward direction of the camera. Here, the dt
field in the :what
block represents the time elapsed between
the
current frame and the previous one, causing the rule to trigger on every frame.
(reg-rule
:player/rotation
{:locals {:forward-temp (v3)
:result-temp (v3)}
:what {:dt {}
:camera {}
:player/model {}}
:when (fn [{{:keys [player/game-started?]} :session}]
game-started?)
:then (fn [{{camera :camera
player-model :player/model} :session
{:keys [forward-temp result-temp]} :locals}]
(let [[yaw offset] (api.camera/get-char-forward-dir {:camera camera
:forward-temp forward-temp
:result-temp result-temp})]
(m/assoc! player-model :rotation.y (+ yaw offset))))})
Network
Writing network code for a fast-paced multiplayer game was a monumental task. I initially chose to handle all the networking myself, thinking it would be a rewarding experience. It was indeed rewarding—but also hellish. I used several Clojure async libraries, including Aleph, Manifold, and core.async, to manage the complexity of real-time communication.
When a player joins a game, a WebSocket connection is established. The backend continuously sends world snapshots to all connected players at a tick rate of 20 (one update every 50 milliseconds). Additionally, specific processes handle actions like spellcasting, damage calculation, and player deaths.
The following snippet registers a process called :super-nova
, which handles the logic for a player casting a Super
Nova
spell. When triggered, the backend processes the event asynchronously, computes the area of effect, and notifies
relevant players.
(reg-pro
:super-nova
(fn [{:keys [id data]}]
(try
(add-super-nova id (:pos data)) ;; Add the visual effect
(apply-range-damage {:current-player-id id
:pos (:pos data)
:radius super-nova-range
:diameter super-nova-diameter
:max-damage 600
:damage-pro-id :got-super-nova-hit}) ;; Calculate damage
(catch Exception e
(log/error e "Super nova hit error!")))))
Area of Effect Damage Calculation
The core of this process is the apply-range-damage function. This function determines which players are affected by a spell, calculates the damage based on proximity, and updates the game state accordingly.
(defn- apply-range-damage [{:keys [current-player-id
pos
radius
height
diameter
max-damage
damage-pro-id
shape-type
damage-over-time
damage-params]
:or {shape-type :sphere}}]
(when-let [room-id (get-room-id-by-player-id current-player-id)]
(let [current-player-ids (set (keys (get-players-with-same-room-id current-player-id)))
my-team (get-player-team current-player-id)
players-within-range (->> (get-world-by-player-id current-player-id)
(keep
(fn [[player-id player-data]]
(let [[x y z] [(:px player-data) (:py player-data) (:pz player-data)]
[x1 y1 z1] pos
player-distance (distance x x1 y y1 z z1)
within-range? (if (= shape-type :cylinder)
(within-cylinder? pos [x y z] radius height)
(<= player-distance radius))]
(when (and (not= player-id current-player-id)
(current-player-ids player-id)
(enemy? room-id player-id my-team)
(> (:health player-data) 0)
(:focus? player-data)
within-range?)
[player-id player-distance])))))
damage-and-positions (for [[player-id distance] players-within-range
:let [damage (generate-damage {:distance distance
:max-damage max-damage
:area-of-affect-diameter diameter
:shape-type shape-type})
damage (get-damage-for-player damage current-player-id player-id)]
:when (> damage 0)]
(let [world (swap! world (fn [world]
(let [health (max 0 (- (get-in world [room-id player-id :health]) damage))
died? (= 0 health)
world (assoc-in world [room-id player-id :health] health)]
(if died?
(assoc-in world [room-id player-id :st] "die")
world))))
died? (= 0 (get-in world [room-id player-id :health]))]
(when died?
(update-stats-after-death current-player-id player-id))
(send! player-id damage-pro-id
(merge {:player-id current-player-id
:damage damage
:died? died?}
damage-params))
(update-last-damage-time player-id)
(add-damage-effect player-id (if (= damage-pro-id :got-ice-tornado-hit)
:ice
:fire))
[player-id damage died?]))]
(when damage-over-time
(register-enemies-for-damage-over-time (now)
current-player-id
room-id
damage-over-time
(map first players-within-range)))
{:damage-and-positions damage-and-positions})))
The process begins by filtering players to identify those in the same room, excluding teammates, and checking if they are within the spell’s radius or cylindrical area. Once the affected players are identified, the damage is calculated, decreasing with distance from the spell’s origin while ensuring it is positive and does not exceed a player’s current health. Finally, the game state is updated by deducting the damage from each player’s health, marking players as “dead” if their health reaches zero, notifying affected players of the damage, and updating the attacker’s stats accordingly.
The backend handles real-time complexity by continuously updating multiple players and ensuring synchronization across all devices. Events such as spellcasting and deaths are processed asynchronously through queues to maintain performance. Developing custom networking code from scratch was both challenging and educational, offering valuable insights into the trade-offs between control and complexity.
The Journey
At the beginning of my game development journey, everything felt fantastic. I was quickly iterating through features, mechanics, and UI development, making substantial progress in short bursts. However, as the project grew, it became increasingly challenging to manage. The difficulty wasn’t directly tied to Clojure’s nature—there were many other contributing factors.
Game development is fundamentally an art of state management. States are everywhere, and managing numerous unrelated systems in harmony is a challenging task. While Clojure’s immutability by default offers many advantages, it also introduces complexity. To handle the intricate state management required for game development, I had to create my own abstractions. Writing a custom DSL (domain-specific language) became a necessity, but it wasn’t easy.
Adding to the challenge was the lack of a strong Clojure game development community. While Clojure excels in domains like SaaS products and finance, its adoption in game development is virtually nonexistent. The absence of shared tools, libraries, and best practices in this space made the journey even more isolating.
I couldn’t help but feel envious of the tooling ecosystems surrounding major game engines like Unity and Unreal Engine. While Babylon.js is a great library, it lacks the robust plugin ecosystems, frameworks, and tools that mainstream engines offer. Developing with Babylon.js often meant building tools from scratch. While this taught me a lot about graphics programming, it came at the cost of time—a critical resource in modern game development.
Good tooling is a cornerstone of game development today. The lack of it left me feeling perpetually one step behind.
Developing 3D games for the web comes with inherent limitations. While WebGL has improved significantly, and WebGPU is on the horizon, web-based games are still far behind native games in terms of performance and graphical fidelity. These limitations force developers to make compromises.
Resource constraints on the web are another hurdle. Users expect quick load times and minimal downloads. Asking players to wait for a 500MB download before they can play is unrealistic. This restricts web games to being small, free, and often simplistic. The result? Web games rarely rival the scale or polish of PC or console games.
Financially, web game development doesn’t make much sense. Monetization options are limited, with ads being the primary choice. But ads disrupt immersion and often force you to design your game around them just to earn a modest income.
The web game market is tiny compared to PC and console markets. While there are a few success stories like Agar.io and Wordle. Only a couple of major web game publishers, like CrazyGames and Poki, exist. Rejection from these platforms can make it nearly impossible to reach a large audience. To sustain yourself financially, you’d need to create a high volume of games in a short period, which is neither practical nor creatively satisfying.
The one advantage web games have is ease of distribution. Sharing a link is all it takes for anyone to jump in and start playing. But this strength alone doesn’t compensate for the many weaknesses.
Making a game is hard; making a successful game is even harder. Creating a good game is like cooking a great meal—you can do wonders with a handful of ingredients or fail miserably with a dozen. There’s no single formula for success. Everything needs to align: timing, optimization, art, sound, trends, and theme.
A few years ago, the battle royale trend exploded, leading to a flood of games trying to capitalize on the craze. Even great games like Spellbreak couldn’t sustain themselves. But following trends isn’t inherently bad; it’s about execution. If done well, it can be a smart business move.
Final Thoughts
Game development is a deeply rewarding yet demanding journey. Whether you’re building games with unconventional tools like Clojure or navigating the limitations of web platforms, the challenges are immense. I’ve learned several key lessons.
First, web games are fantastic for prototyping thanks to their ease of distribution, but resource constraints make them unsuitable for larger projects.
Second, Clojure’s REPL and functional paradigm enabled rapid iteration, but its niche nature and lack of game development resources added unnecessary difficulty.
Lastly, tooling is crucial—while building custom tools taught me a lot, the absence of robust, ready-made tools significantly slowed progress. Moving forward, transitioning to a mainstream engine like Unity or Unreal could streamline development and allow me to focus more on the creative aspects of game design.
Ultimately, the act of creating games—bringing ideas to life—remains an unparalleled experience, with each challenge offering invaluable lessons.
It’s not just about the end result but the lessons learned along the way.