Skip to content

Commit 75510f4

Browse files
authored
Merge pull request #143 from burinc/make-galaga-more-testable
refactor(galaga): apply pure function pattern with state at edges
2 parents 54e5564 + 9cb2857 commit 75510f4

File tree

2 files changed

+353
-54
lines changed

2 files changed

+353
-54
lines changed

src/scittle/games/galaga.clj

Lines changed: 291 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,292 @@
394394

395395
;; This same issue affected our Asteroids game, and the batched approach solved both!
396396

397+
;; ## Erik's Pure Function Pattern: State at the Edges
398+
399+
;; After the collision batching improvements, [Erik Assum](https://github.com/slipset) shared an even more profound insight about functional programming and state management:
400+
401+
;; > "swap! takes an f state arg1 arg2 etc as params. So rather than passing anon fns to swap! You can pass named toplevel fns which take the state as first args, and whatever other args that fn might need, and your swap! becomes (swap! game-state do-whatever foo bar baz)"
402+
403+
;; > "What you'll see when you structure the code like that is that the state transforming fns become pure fns from data to data and are trivially unittestable (apart from your random-stuff which is inherently impure), and that the icky bits ie mutating the global state can be moved towards the edge of the program."
404+
405+
;; This principle - **pushing state mutations to the edges** - transforms how we structure game code.
406+
407+
;; ### Understanding Multi-Argument swap!
408+
409+
;; Most developers know `swap!` applies a function to an atom:
410+
411+
;; ```clojure
412+
;; (swap! atom update-fn)
413+
;; ```
414+
415+
;; But `swap!` can also pass additional arguments:
416+
417+
;; ```clojure
418+
;; ;; swap! signature: (swap! atom f & args)
419+
;; ;; This calls: (f @atom arg1 arg2 arg3)
420+
;; (swap! game-state my-function arg1 arg2 arg3)
421+
;; ```
422+
423+
;; The function receives the **current state as its first argument**, followed by any additional arguments you provide!
424+
425+
;; ### Before: Impure Functions with Internal State Mutations
426+
427+
;; Our original Galaga code had functions that read `@game-state` and called `swap!` internally:
428+
429+
;; ```clojure
430+
;; ;; ❌ Old approach: Impure function with internal swap!
431+
;; (defn fire-bullet! []
432+
;; (play-laser-sound)
433+
;; (let [{:keys [player]} @game-state] ; Reading global state
434+
;; (swap! game-state update :bullets ; Mutating global state
435+
;; #(conj % {:x (:x player) :y (:y player) ...}))))
436+
;;
437+
;; ;; ❌ Old approach: Function tightly coupled to global atom
438+
;; (defn move-player! [& {:keys [direction]}]
439+
;; (swap! game-state update-in [:player :x] ; Internal mutation
440+
;; #(-> %
441+
;; (+ (* direction player-speed))
442+
;; (max (/ player-width 2))
443+
;; (min (- canvas-width (/ player-width 2))))))
444+
;;
445+
;; ;; ❌ Old approach: Reading and mutating inside the function
446+
;; (defn start-swoop! []
447+
;; (let [{:keys [enemies]} @game-state] ; Reading global state
448+
;; (when (and (> (count (filter ...)) 0)
449+
;; (< (rand) 0.02))
450+
;; (play-swoop-sound)
451+
;; (swap! game-state update :enemies ...)))) ; Internal mutation
452+
;; ```
453+
454+
;; **Problems with this approach:**
455+
;;
456+
;; - Functions are **impure** - they read from and write to global state
457+
;; - **Hard to test** - requires setting up the global atom
458+
;; - **Difficult to reason about** - state mutations hidden inside functions
459+
;; - **Not composable** - can't chain or combine transformations
460+
;; - **Not reusable** - tightly coupled to the specific `game-state` atom
461+
462+
;; ### After: Pure Functions Taking State as First Argument
463+
464+
;; Following Erik's pattern, we refactored all state transformation functions to be pure:
465+
466+
;; ```clojure
467+
;; ;; ✅ New approach: Pure function taking state as first argument
468+
;; (defn fire-bullet!
469+
;; "Fires a bullet from the player - returns new state"
470+
;; [game-state]
471+
;; (play-laser-sound) ; Side effect at the edge
472+
;; (let [{:keys [player]} game-state] ; No @game-state read!
473+
;; (update game-state :bullets
474+
;; #(conj % {:x (:x player) :y (:y player) ...}))))
475+
;;
476+
;; ;; Usage: Clean and explicit!
477+
;; (swap! game-state fire-bullet!)
478+
;;
479+
;; ;; ✅ New approach: Pure function with keyword arguments
480+
;; (defn move-player!
481+
;; "Moves player left or right - returns new state"
482+
;; [game-state & {:keys [direction]}]
483+
;; (update-in game-state [:player :x]
484+
;; #(-> %
485+
;; (+ (* direction player-speed))
486+
;; (max (/ player-width 2))
487+
;; (min (- canvas-width (/ player-width 2))))))
488+
;;
489+
;; ;; Usage: Pass additional arguments after the function!
490+
;; (swap! game-state move-player! :direction 1)
491+
;;
492+
;; ;; ✅ New approach: Returns new state or unchanged state
493+
;; (defn start-swoop!
494+
;; "Initiates enemy swoop attack - returns new state"
495+
;; [game-state]
496+
;; (let [{:keys [enemies]} game-state] ; No @game-state read!
497+
;; formation-enemies (filter #(= (:state %) :formation) enemies)]
498+
;; (if (and (> (count formation-enemies) 0)
499+
;; (< (rand) 0.02))
500+
;; (let [enemy (rand-nth formation-enemies)
501+
;; path (generate-swoop-path ...)]
502+
;; (play-swoop-sound) ; Side effect at the edge
503+
;; (update game-state :enemies
504+
;; (fn [enemies]
505+
;; (mapv #(if (= % enemy)
506+
;; (assoc % :state :swooping ...)
507+
;; %)
508+
;; enemies))))
509+
;; ;; No swoop - return unchanged state
510+
;; game-state)))
511+
;;
512+
;; ;; Usage: Simple and clean!
513+
;; (swap! game-state start-swoop!)
514+
;; ```
515+
516+
;; ### More Examples: init-wave! and enemy-fire!
517+
518+
;; All game functions now follow this pattern:
519+
520+
;; ```clojure
521+
;; ;; ✅ Pure wave initialization
522+
;; (defn init-wave!
523+
;; "Initializes a new wave - returns new state"
524+
;; [game-state & {:keys [wave-num]}]
525+
;; (let [new-enemies (vec (map create-enemy formation-positions))]
526+
;; (assoc game-state
527+
;; :enemies new-enemies
528+
;; :bullets []
529+
;; :enemy-bullets []
530+
;; :formation-offset {:x 0 :y 0})))
531+
;;
532+
;; ;; Usage:
533+
;; (swap! game-state init-wave! :wave-num 1)
534+
;;
535+
;; ;; ✅ Pure enemy firing
536+
;; (defn enemy-fire!
537+
;; "Enemy fires bullet at player - returns new state"
538+
;; [game-state & {:keys [enemy]}]
539+
;; (update game-state :enemy-bullets
540+
;; #(conj % {:x (:x enemy)
541+
;; :y (+ (:y enemy) enemy-height) ...})))
542+
;;
543+
;; ;; Usage with multiple enemies - single swap! with reduce!
544+
;; (when (seq firing-enemies)
545+
;; (swap! game-state
546+
;; (fn [state]
547+
;; (reduce (fn [s enemy]
548+
;; (enemy-fire! s :enemy enemy))
549+
;; state
550+
;; firing-enemies))))
551+
;; ```
552+
553+
;; ### Benefits of Pure Functions
554+
555+
;; **1. Trivially Testable**
556+
557+
;; No atoms needed - just pass data, get data back:
558+
559+
;; ```clojure
560+
;; (deftest move-player-test
561+
;; (let [initial-state {:player {:x 240}}
562+
;; new-state (move-player! initial-state :direction 1)]
563+
;; (is (> (get-in new-state [:player :x]) 240))))
564+
;;
565+
;; (deftest fire-bullet-test
566+
;; (let [initial-state {:player {:x 240 :y 560} :bullets []}
567+
;; new-state (fire-bullet! initial-state)]
568+
;; (is (= 1 (count (:bullets new-state))))
569+
;; (is (= 240 (-> new-state :bullets first :x)))))
570+
;;
571+
;; (deftest start-swoop-no-enemies-test
572+
;; (let [initial-state {:enemies []}
573+
;; new-state (start-swoop! initial-state)]
574+
;; (is (= initial-state new-state)))) ; Returns unchanged!
575+
;; ```
576+
577+
;; **2. State Mutations at Program Edges**
578+
579+
;; All `swap!` calls are now at the boundaries:
580+
581+
;; ```clojure
582+
;; ;; In event handlers (edge of the program)
583+
;; (.addEventListener js/window "keydown"
584+
;; (fn [e]
585+
;; (case (.-key e)
586+
;; " " (swap! game-state fire-bullet!) ; Clean!
587+
;; nil)))
588+
;;
589+
;; ;; In the game loop (edge of the program)
590+
;; (when (contains? @keys-pressed "ArrowLeft")
591+
;; (swap! game-state move-player! :direction -1))
592+
;;
593+
;; (when (contains? @keys-pressed "ArrowRight")
594+
;; (swap! game-state move-player! :direction 1))
595+
;;
596+
;; ;; In the game loop (edge of the program)
597+
;; (swap! game-state start-swoop!)
598+
;; ```
599+
600+
;; **3. Composable and Reusable**
601+
602+
;; Pure functions can be composed with threading macros:
603+
604+
;; ```clojure
605+
;; ;; Test sequences without atoms!
606+
;; (-> initial-state
607+
;; (move-player! :direction 1)
608+
;; (fire-bullet!)
609+
;; (start-swoop!)
610+
;; (:bullets)
611+
;; (count))
612+
;;
613+
;; ;; Batch multiple transformations
614+
;; (reduce (fn [state enemy]
615+
;; (enemy-fire! state :enemy enemy))
616+
;; initial-state
617+
;; firing-enemies)
618+
;; ```
619+
620+
;; **4. Easier to Reason About**
621+
622+
;; ```clojure
623+
;; ;; ❌ Before: What does this do? Need to read the implementation
624+
;; (fire-bullet!)
625+
;;
626+
;; ;; ✅ After: Clear data transformation
627+
;; (swap! game-state fire-bullet!)
628+
;; ;; I know: takes current state, returns new state with bullet added
629+
;; ```
630+
631+
;; ### The Pattern in Action
632+
633+
;; Here's how it looks in the actual game code:
634+
635+
;; ```clojure
636+
;; ;; Game initialization
637+
;; (defn start-game! []
638+
;; (swap! game-state assoc
639+
;; :player {:x 240 :y 560 :lives 3}
640+
;; :bullets []
641+
;; :enemy-bullets []
642+
;; :enemies []
643+
;; :wave 1
644+
;; :score 0
645+
;; :game-status :playing ...)
646+
;; (swap! game-state init-wave! :wave-num 1)) ; Pure function call!
647+
;;
648+
;; ;; In the game loop
649+
;; (swap! game-state start-swoop!)
650+
;;
651+
;; ;; Batch enemy firing - single swap!
652+
;; (when (seq firing-enemies)
653+
;; (swap! game-state
654+
;; (fn [state]
655+
;; (reduce (fn [s enemy]
656+
;; (enemy-fire! s :enemy enemy))
657+
;; state
658+
;; firing-enemies))))
659+
;;
660+
;; ;; Wave completion
661+
;; (when (empty? enemies)
662+
;; (play-wave-complete-sound)
663+
;; (swap! game-state update :wave inc)
664+
;; (js/setTimeout
665+
;; #(swap! game-state init-wave! :wave-num (:wave @game-state))
666+
;; 500))
667+
;; ```
668+
669+
;; ### Key Principles
670+
671+
;; 1. **`swap!` multi-argument form**: `(swap! atom f arg1 arg2)` calls `(f @atom arg1 arg2)`
672+
;; 2. **State as first argument**: Functions receive state, return new state
673+
;; 3. **No `@game-state` reads**: Functions operate on passed state, not global atom
674+
;; 4. **No internal `swap!`**: All mutations happen at program edges
675+
;; 5. **Pure data transformations**: Core game logic is pure functions
676+
;; 6. **Side effects at edges**: Sounds, randomness, I/O happen at boundaries
677+
678+
;; This pattern transforms imperative, stateful code into functional, testable data transformations. As Erik noted: "the state transforming fns become pure fns from data to data and are trivially unittestable."
679+
680+
;; Thank you again, Erik, for these profound insights!
681+
;; Both the collision batching pattern and the pure function pattern have dramatically improved the codebase. 🙏
682+
397683
;; ## Visual Effects
398684

399685
;; ### Particle System
@@ -443,7 +729,7 @@
443729

444730
;; ## Arcade-Style Sound Effects
445731

446-
;; What's an arcade game without sound? We use the **Web Audio API** to create procedural sound effects that bring the game to life!
732+
;; What's an arcade game without sound? We use the Web Audio API to create procedural sound effects that bring the game to life!
447733

448734
;; ### Setting Up Audio
449735

@@ -551,6 +837,7 @@
551837
;; - **Cross-browser** - Works everywhere JavaScript runs
552838

553839
;; The frequency values are chosen to be distinctive yet not annoying:
840+
;;
554841
;; - **High frequencies (600-800Hz)**: Action sounds (lasers, swoops)
555842
;; - **Low frequencies (100-150Hz)**: Impact sounds (explosions, hits)
556843
;; - **Musical notes (C-E-G-C)**: Victory celebrations
@@ -618,15 +905,12 @@
618905
;; (fn [e]
619906
;; (.preventDefault e)
620907
;; (when on-press (on-press)))
621-
;;
622908
;; handle-touch-end
623909
;; (fn [e]
624910
;; (.preventDefault e)
625911
;; (when on-release (on-release)))]
626-
;;
627912
;; (.addEventListener button \"touchstart\" handle-touch-start)
628913
;; (.addEventListener button \"touchend\" handle-touch-end))))
629-
;;
630914
;; :render
631915
;; (fn []
632916
;; [:div {:ref #(reset! button-ref %)
@@ -638,6 +922,7 @@
638922
;; ```
639923

640924
;; Three buttons provide full control:
925+
;;
641926
;; - **Left Arrow (◀)**: Move left
642927
;; - **Right Arrow (▶)**: Move right
643928
;; - **FIRE Button**: Shoot bullets
@@ -736,10 +1021,12 @@
7361021
;; The complete Galaga game is embedded below. Works on both desktop and mobile!
7371022

7381023
;; **Desktop Controls:**
1024+
;;
7391025
;; - Arrow keys to move left/right
7401026
;; - Spacebar to fire
7411027

7421028
;; **Mobile Controls:**
1029+
;;
7431030
;; - Touch buttons (bottom-left) to move
7441031
;; - FIRE button (bottom-right) to shoot
7451032

0 commit comments

Comments
 (0)