|
394 | 394 |
|
395 | 395 | ;; This same issue affected our Asteroids game, and the batched approach solved both! |
396 | 396 |
|
| 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 | + |
397 | 683 | ;; ## Visual Effects |
398 | 684 |
|
399 | 685 | ;; ### Particle System |
|
443 | 729 |
|
444 | 730 | ;; ## Arcade-Style Sound Effects |
445 | 731 |
|
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! |
447 | 733 |
|
448 | 734 | ;; ### Setting Up Audio |
449 | 735 |
|
|
551 | 837 | ;; - **Cross-browser** - Works everywhere JavaScript runs |
552 | 838 |
|
553 | 839 | ;; The frequency values are chosen to be distinctive yet not annoying: |
| 840 | +;; |
554 | 841 | ;; - **High frequencies (600-800Hz)**: Action sounds (lasers, swoops) |
555 | 842 | ;; - **Low frequencies (100-150Hz)**: Impact sounds (explosions, hits) |
556 | 843 | ;; - **Musical notes (C-E-G-C)**: Victory celebrations |
|
618 | 905 | ;; (fn [e] |
619 | 906 | ;; (.preventDefault e) |
620 | 907 | ;; (when on-press (on-press))) |
621 | | -;; |
622 | 908 | ;; handle-touch-end |
623 | 909 | ;; (fn [e] |
624 | 910 | ;; (.preventDefault e) |
625 | 911 | ;; (when on-release (on-release)))] |
626 | | -;; |
627 | 912 | ;; (.addEventListener button \"touchstart\" handle-touch-start) |
628 | 913 | ;; (.addEventListener button \"touchend\" handle-touch-end)))) |
629 | | -;; |
630 | 914 | ;; :render |
631 | 915 | ;; (fn [] |
632 | 916 | ;; [:div {:ref #(reset! button-ref %) |
|
638 | 922 | ;; ``` |
639 | 923 |
|
640 | 924 | ;; Three buttons provide full control: |
| 925 | +;; |
641 | 926 | ;; - **Left Arrow (◀)**: Move left |
642 | 927 | ;; - **Right Arrow (▶)**: Move right |
643 | 928 | ;; - **FIRE Button**: Shoot bullets |
|
736 | 1021 | ;; The complete Galaga game is embedded below. Works on both desktop and mobile! |
737 | 1022 |
|
738 | 1023 | ;; **Desktop Controls:** |
| 1024 | +;; |
739 | 1025 | ;; - Arrow keys to move left/right |
740 | 1026 | ;; - Spacebar to fire |
741 | 1027 |
|
742 | 1028 | ;; **Mobile Controls:** |
| 1029 | +;; |
743 | 1030 | ;; - Touch buttons (bottom-left) to move |
744 | 1031 | ;; - FIRE button (bottom-right) to shoot |
745 | 1032 |
|
|
0 commit comments