Oct. 3, 2024, 9:21 a.m.

Breakout Game in ClojureDart

Curiosities by Tensegritics

This issue is about following the Flame’s Brick Breaker tutorial in ClojureDart.

This was prompted by Ian Chow (who just released a CLJD app onto the stores) mentioning, en passant, he struggled to port this tutorial.

We couldn’t let this slip, so here we go! This is going to be code-heavy, and not very clojurey as we were not familiar with the Flame framework.

If you are impatient, skip to the 🕹️ emojis.


🧑‍💻 Our Consulting Services 🧑‍💻

If you are looking into building a desktop, mobile or even web app, get in touch with us we will help you at multiple levels from technical support and expertise to building it for you and with you.


🗞️ News and Links: App Announcement, Re-Dash Inspector and HYTRADBOI 2025 🗞️

App announcement

Kudos to Ian Chow who released a new ClojureDart app to the stores 🎉.

Re-Dash Inspector

The re-dash inspector
by Werner Kok is very very cool 🤯 for two reasons:

  • it's an extension to the Flutter devtools (akin to browser devtools) to help with re-dash apps (re-dash is "re-frame for ClojureDart and Flutter"),
  • and it's written in ClojureDart!

HYTRADBOI 2025

It's not about Clojure but... HYTRADBOI conf is back!!!

"Have You Tried Rubbing A DB On It?" (HYTRADBOI) was a really great conf (go watch the videos!) and it's happening anew in February 2025. There will be a Programming Languages track too!

It's fresh, cheap, online and async. You can't afford to miss it!

More formal but Datalog 2.0 2024 will be held next week (and on Christophe's birthday!). We hope videos and papers surface soon!


🕹️ Flame's breakout in ClojureDart 🕹️

Open the original tutorial in a separate tab as the focus is on translating Dart to ClojureDart, we won't repeat the tutorial explanations.

At each step, new or modified code is pointed to by 👇 or 👈.

This is going to be code-heavy, so feel free to jump to the end for our conclusions!

image.png

Project setup

Let's create the project directory and add Flutter to the path.

mkdir brickbreaker
cd brickbreaker
fvm use
clj -M:cljd init

Then add required dependencies:

fvm flutter pub add flame flutter_animate google_fonts

Create src/brickbreaker/main.cljd:

(ns brickbreaker.main
  (:require
   ["package:flame/game.dart" :as game]
   ["package:flutter/material.dart" :as m]
   [cljd.flutter :as f]))

(defn main []
  (f/run
    (game/GameWidget .game (game/FlameGame))))

and then

clj -M:cljd flutter -d macos ; adjust to your target

Et voilà we are caught up with step #3

image.png

Step #4: Create the game

First we introduce the PlayArea, I use a deftype for now, maybe a reify would suffice. I don't know: I'm writing this as I go. In the same vein the mixin is parametrized by a yet undefined class (BrickBreaker) so I omit it for now. Maybe we won't even need it.

(ns brickbreaker.main
  (:require
   ["package:flame/game.dart" :as game]
   ["package:flutter/material.dart" :as m]
   ["dart:async" :as async] ; 👈
   ["package:flame/components.dart" :as components] ; 👈
   [cljd.flutter :as f]))

(def game-width 820.0) ; 👈
(def game-height 1600.0) ; 👈

; 👇
(deftype PlayArea []
  :extends (components/RectangleComponent .paint (doto (m/Paint) (.-color! (m/Color 0xfff2e8cf))))
  (onLoad [this]
    (.onLoad ^super this)
    (.-size! this (game/Vector2 game-width game-height)))
  ^:mixin components/HasGameReference) ; there's a <BrickBreaker> ref but I don't know yet where we are going

(defn main []
  (f/run
    (game/GameWidget .game (game/FlameGame))))

Next the missing BrickBreaker class
(it's a direct link to the relevant section but then some JS kicks in and decide that you can't link in the middle of a page 🙄)

(ns brickbreaker.main
  (:require
   ["package:flame/game.dart" :as game]
   ["package:flutter/material.dart" :as m]
   ["dart:async" :as async]
   ["package:flame/components.dart" :as components]
   [cljd.flutter :as f]))

(def game-width 820.0)
(def game-height 1600.0)

(deftype PlayArea []
  :extends (components/RectangleComponent .paint (doto (m/Paint) (.-color! (m/Color 0xfff2e8cf))))
  (onLoad [this]
    (.onLoad ^super this)
    (.-size! this (game/Vector2 game-width game-height)))
  ^:mixin components/HasGameReference) ; there's a <BrickBreaker> ref but I don't know yet where we are going

; 👇
(deftype BrickBreaker []
    :extends (game/FlameGame
               .camera (components/CameraComponent.withFixedResolution
                         .width game-width
                         .height game-height))
    (onLoad [this]
      (.onLoad  ^super this)
      (-> this .-camera .-viewfinder (.-anchor! components/Anchor.topLeft))
      (-> this .-world (.add (PlayArea))))
    BrickBreaker ; 👇 I have a bad feeling about these getters
    (^:getter width [this] (-> this .-size .-x))
    (^:getter height [this] (-> this .-size .-x)))

(defn main []
  (f/run
    (game/GameWidget .game (BrickBreaker) #_👈)))

Behold the beige rectangle!

Step #5: Display the ball

Are things getting serious yet?

(ns brickbreaker.main
  (:require
   ["package:flame/game.dart" :as game]
   ["package:flutter/material.dart" :as m]
   ["dart:async" :as async]
   ["package:flame/components.dart" :as components]
   [cljd.flutter :as f]))

(def game-width 820.0)
(def game-height 1600.0)
(def ball-radius (* game-width 0.02))

(deftype PlayArea []
  :extends (components/RectangleComponent .paint (doto (m/Paint) (.-color! (m/Color 0xfff2e8cf))))
  (onLoad [this]
    (.onLoad ^super this)
    (.-size! this (game/Vector2 game-width game-height)))
  ^:mixin components/HasGameReference) ; there's a <BrickBreaker> ref but I don't know yet where we are going

; 👇
(deftype Ball [^game/Vector2 velocity]
  :extends (components/CircleComponent
             .anchor components/Anchor.center
             .paint (doto (m/Paint)
                      (.-color! (m/Color 0xff1e6091))
                      (.-style! m/PaintingStyle.fill)))
  (update [this dt]
    (.update ^super this dt)
    ; 👇 .+ and .* because we call the native + and * operators which are not restricted to numbers 
    (.-position! this (.+ (.-position this) (.* velocity dt)))
    nil))

; 👇 creating a constructor function to pass position radius
; maybe we should allow the ^super type hint onto fields in deftype
(defn ball [velocity position radius]
  (doto (Ball velocity)
    (.-position! position)
    (.-radius! radius)))

(deftype BrickBreaker []
  :extends (game/FlameGame
             .camera (components/CameraComponent.withFixedResolution
                       .width game-width
                       .height game-height))
  (onLoad [this]
    (.onLoad  ^super this)
    (-> this .-camera .-viewfinder (.-anchor! components/Anchor.topLeft))
    (doto (.-world this)
      (.add (PlayArea))
      ; 👇
      (.add (ball
              (game/Vector2 (* (- (rand) 0.5) (.-width this)) (* 0.2 (.-height this)))
              ; 👇 calling the native / operator but ./ is not a valid symbol so we have to desugar into this form
              (. (.-size this) / 2)
              ball-radius)))
    (.-debugMode! this true)) ; 👈
  BrickBreaker
  (^:getter width [this] (-> this .-size .-x))
  (^:getter height [this] (-> this .-size .-x)))

(defn main []
  (f/run
    (game/GameWidget .game (BrickBreaker))))

Enregistrement de l’écran 2024-09-24 à 10.03.06.gif

Step #6: Bounce Around

Still following the original tutorial we now add collision with the walls.

(ns brickbreaker.main
  (:require
   ["package:flame/game.dart" :as game]
   ["package:flutter/material.dart" :as m]
   ["dart:async" :as async]
   ["package:flame/collisions.dart" :as collisions] ; 👈
   ["package:flame/components.dart" :as components]
   [cljd.flutter :as f]))

(def game-width 820.0)
(def game-height 1600.0)
(def ball-radius (* game-width 0.02))

(deftype PlayArea []
  :extends (components/RectangleComponent
             .paint (doto (m/Paint) (.-color! (m/Color 0xfff2e8cf)))
             .children [(collisions/RectangleHitbox)]) ; 👈
  (onLoad [this]
    (.onLoad ^super this)
    (.-size! this (game/Vector2 game-width game-height)))
  ^:mixin components/HasGameReference) ; there's a <BrickBreaker> ref but I don't know yet where we are going

(deftype Ball [^game/Vector2 velocity]
  :extends (components/CircleComponent
             .anchor components/Anchor.center
             .paint (doto (m/Paint)
                      (.-color! (m/Color 0xff1e6091))
                      (.-style! m/PaintingStyle.fill))
             .children [(collisions/CircleHitbox)])  ; 👈
  (update [this dt]
    (.update ^super this dt)
    (.-position! this (.+ (.-position this) (.* velocity dt)))
    nil)
  ^:mixin components/HasGameReference ; 👈 same as before I omit the type parameter for now
  ^:mixin collisions/CollisionCallbacks ; 👈
  ; 👇
  (onCollisionStart [this intersection-points other]
    (.onCollisionStart ^super this intersection-points other)
    (cond
      (not (instance? PlayArea other)) (println "collision with" other)
      ; deliberaely not trying to simplify/refactor this
      (<= (-> intersection-points .-first .-x) 0) (.-x! velocity (- (.-x velocity)))
      (<= (-> this .-game .-width) (-> intersection-points .-first .-x)) (.-x! velocity (- (.-x velocity)))
      (<= (-> this .-game .-height) (-> intersection-points .-first .-y)) (.removeFromParent this))
    nil))

(defn ball [velocity position radius]
  (doto (Ball velocity)
    (.-position! position)
    (.-radius! radius)))

(deftype BrickBreaker []
  :extends (game/FlameGame
             .camera (components/CameraComponent.withFixedResolution
                       .width game-width
                       .height game-height))
  (onLoad [this]
    (.onLoad  ^super this)
    (-> this .-camera .-viewfinder (.-anchor! components/Anchor.topLeft))
    (doto (.-world this)
      (.add (PlayArea))
      (.add (ball
              (game/Vector2 (* (- (rand) 0.5) (.-width this)) (* 0.2 (.-height this)))
              (. (.-size this) / 2)
              ball-radius)))
    (.-debugMode! this true))
  ^:mixin game/HasCollisionDetection ; 👈
  BrickBreaker
  (^:getter width [this] (-> this .-size .-x))
  (^:getter height [this] (-> this .-size .-x)))

(defn main []
  (f/run
    (game/GameWidget .game (BrickBreaker))))

Step #7: Get bat on ball

(ns brickbreaker.main
  (:require
   ["package:flame/game.dart" :as game]
   ["package:flutter/material.dart" :as m]
   ["package:flutter/services.dart" :as services] ; 👈
   ["dart:async" :as async]
   ["package:flame/collisions.dart" :as collisions]
   ["package:flame/components.dart" :as components]
   ["package:flame/effects.dart" :as effects] ; 👈
   ["package:flame/events.dart" :as events] ; 👈
   [cljd.flutter :as f]))

(def game-width 820.0)
(def game-height 1600.0)
(def ball-radius (* game-width 0.02))
(def bat-width (* game-width 0.2)) ; 👈
(def bat-height (* ball-radius 2)) ; 👈
(def bat-step (* game-width 0.05)) ; 👈

(deftype PlayArea []
  :extends (components/RectangleComponent
             .paint (doto (m/Paint) (.-color! (m/Color 0xfff2e8cf)))
             .children [(collisions/RectangleHitbox)])
  (onLoad [this]
    (.onLoad ^super this)
    (.-size! this (game/Vector2 game-width game-height)))
  ^:mixin components/HasGameReference) ; there's a <BrickBreaker> ref but I don't know yet where we are going

(deftype Ball [^game/Vector2 velocity]
  :extends (components/CircleComponent
             .anchor components/Anchor.center
             .paint (doto (m/Paint)
                      (.-color! (m/Color 0xff1e6091))
                      (.-style! m/PaintingStyle.fill))
             .children [(collisions/CircleHitbox)])
  (update [this dt]
    (.update ^super this dt)
    (.-position! this (.+ (.-position this) (.* velocity dt)))
    nil)
  ^:mixin components/HasGameReference
  ^:mixin collisions/CollisionCallbacks
  (onCollisionStart [this intersection-points other]
    (.onCollisionStart ^super this intersection-points other)
    (cond
      (instance? PlayArea other)
      (cond
        (<= (-> intersection-points .-first .-y) 0) (.-y! velocity (- (.-y velocity)))
        (<= (-> intersection-points .-first .-x) 0) (.-x! velocity (- (.-x velocity)))
        (<= (-> this .-game .-width) (-> intersection-points .-first .-x)) (.-x! velocity (- (.-x velocity)))
        (<= (-> this .-game .-height) (-> intersection-points .-first .-y))
        ; 👇
        (.add this (effects/RemoveEffect .delay 0.35)))
      (instance? Bat other) ; 👈
      ; 👇
      (do
        (.-y! velocity (- (.-y velocity)))
        (.-x! velocity (+ (.-x velocity)
                         (* (- (-> this .-position .-x) (-> other .-position .-x))
                           (-> other .-size .-x /)
                           (-> this .-game .-width)
                           0.3))))
      :else (println "collision with" other))
    nil))

(defn ball [velocity position radius]
  (doto (Ball velocity)
    (.-position! position)
    (.-radius! radius)))

; 👇
(deftype Bat [corner-radius]
  :extends (components/PositionComponent
             .anchor components/Anchor.center
             .children [(collisions/RectangleHitbox)])
  (render [this canvas]
    (.render ^super this canvas)
    (.drawRRect canvas
      (m/RRect.fromRectAndRadius
        (.& m/Offset.zero (let [{:flds [x y]} (.-size this)] (m/Size x y))) ; .toSize is provided by an extension
        corner-radius)
      (doto (m/Paint)
        (.-color! (m/Color 0xff1e6091))
        (.-style! (m/PaintingStyle.fill)))))
  Bat
  (moveBy [this dx]
    (.add this
      (effects/MoveToEffect
        (game/Vector2
          (.clamp (+ (-> this .-position .-x) dx) 0 (-> this .-game .-width))
          (-> this .-position .-y))
        (effects/EffectController .duration 0.1))))
  ^:mixin events/DragCallbacks
  (onDragUpdate [this event]
    (.onDragUpdate ^super this event)
    (.-x! (.-position this)
      (.clamp (+ (-> this .-position .-x) (-> event .-localDelta .-x)) 0 ^double (-> this .-game .-width)))
    nil)
  ^:mixin components/HasGameReference)

; 👇
(defn bat [corner-radius position size]
  (doto (Bat corner-radius)
    (.-position! position)
    (.-size! size)))

(deftype BrickBreaker []
  :extends (game/FlameGame
             .camera (components/CameraComponent.withFixedResolution
                       .width game-width
                       .height game-height))
  (onLoad [this]
    (.onLoad  ^super this)
    (-> this .-camera .-viewfinder (.-anchor! components/Anchor.topLeft))
    (doto (.-world this)
      (.add (PlayArea))
      (.add (ball
              (game/Vector2 (* (- (rand) 0.5) (.-width this)) (* 0.2 (.-height this)))
              (. (.-size this) / 2)
              ball-radius))
      ; 👇
      (.add (bat
              (m/Radius.circular (/ ball-radius 2))
              (game/Vector2 (/ (.-width this) 2) (* (.-height this) 0.95))
              (game/Vector2 bat-width bat-height))))
    (.-debugMode! this true))
  ^:mixin game/HasCollisionDetection
  ^:mixin events/KeyboardEvents ; 👈
  ; 👇
  (onKeyEvent [this event keys-pressed]
    (.onKeyEvent ^super this event keys-pressed)
    (condp = (.-logicalKey event)
      services/LogicalKeyboardKey.arrowLeft
      (-> this .-world .-children (#/(.query Bat) #_🙄) .-first (.moveBy (- bat-step)) )
      services/LogicalKeyboardKey.arrowRight
      (-> this .-world .-children (#/(.query Bat) #_🙄) .-first (.moveBy bat-step) ))
    m/KeyEventResult.handled)
  BrickBreaker
  (^:getter width [this] (-> this .-size .-x))
  (^:getter height [this] (-> this .-size .-y))) ; 🐞

(defn main []
  (f/run
    (game/GameWidget .game (BrickBreaker))))

Enregistrement de l’écran 2024-10-02 à 12.15.05.gif

Step #8: Break down the wall

(ns brickbreaker.main
  (:require
   ["package:flame/game.dart" :as game]
   ["package:flutter/material.dart" :as m]
   ["package:flutter/services.dart" :as services]
   ["dart:async" :as async]
   ["package:flame/collisions.dart" :as collisions]
   ["package:flame/components.dart" :as components]
   ["package:flame/effects.dart" :as effects]
   ["package:flame/events.dart" :as events]
   [cljd.flutter :as f]))

(def game-width 820.0)
(def game-height 1600.0)
(def ball-radius (* game-width 0.02))
(def bat-width (* game-width 0.2))
(def bat-height (* ball-radius 2))
(def bat-step (* game-width 0.05))
; 👇
(def brick-colors [(m/Color 0xfff94144),
                   (m/Color 0xfff3722c),
                   (m/Color 0xfff8961e),
                   (m/Color 0xfff9844a),
                   (m/Color 0xfff9c74f),
                   (m/Color 0xff90be6d),
                   (m/Color 0xff43aa8b),
                   (m/Color 0xff4d908e),
                   (m/Color 0xff277da1),
                   (m/Color 0xff577590)])
(def brick-gutter (* game-width 0.015)) ; 👈
(def brick-width (/ (- game-width (* brick-gutter (inc (count brick-colors)))) ; 👈
                   (count brick-colors))) ; 👈
(def brick-height (* game-height 0.03)) ; 👈
(def difficulty-modifier 1.03) ; 👈

(deftype PlayArea []
  :extends (components/RectangleComponent
             .paint (doto (m/Paint) (.-color! (m/Color 0xfff2e8cf)))
             .children [(collisions/RectangleHitbox)])
  (onLoad [this]
    (.onLoad ^super this)
    (.-size! this (game/Vector2 game-width game-height)))
  ^:mixin components/HasGameReference) ; there's a <BrickBreaker> ref but I don't know yet where we are going

(deftype Ball [^game/Vector2 velocity difficulty-modifier #_👈]
  :extends (components/CircleComponent
             .anchor components/Anchor.center
             .paint (doto (m/Paint)
                      (.-color! (m/Color 0xff1e6091))
                      (.-style! m/PaintingStyle.fill))
             .children [(collisions/CircleHitbox)])
  (update [this dt]
    (.update ^super this dt)
    (.-position! this (.+ (.-position this) (.* velocity dt)))
    nil)
  ^:mixin components/HasGameReference
  ^:mixin collisions/CollisionCallbacks
  (onCollisionStart [this intersection-points other]
    (.onCollisionStart ^super this intersection-points other)
    (cond
      (instance? PlayArea other)
      (cond
        (<= (-> intersection-points .-first .-y) 0) (.-y! velocity (- (.-y velocity)))
        (<= (-> intersection-points .-first .-x) 0) (.-x! velocity (- (.-x velocity)))
        (<= (-> this .-game .-width) (-> intersection-points .-first .-x)) (.-x! velocity (- (.-x velocity)))
        (<= (-> this .-game .-height) (-> intersection-points .-first .-y))
        (.add this (effects/RemoveEffect .delay 0.35)))
      (instance? Bat other)
      (do
        (.-y! velocity (- (.-y velocity)))
        (.-x! velocity (+ (.-x velocity)
                         (* (- (-> this .-position .-x) (-> other .-position .-x))
                           (-> other .-size .-x /)
                           (-> this .-game .-width)
                           0.3))))
      (instance? Brick other) ; 👈
      ; 👇
      (do
        (cond
          (< (-> this .-position .-y) (- (-> other .-position .-y) (/ (-> other .-size .-y) 2)))
          (.-y! velocity (- (.-y velocity)))
          (> (-> this .-position .-y) (+ (-> other .-position .-y) (/ (-> other .-size .-y) 2)))
          (.-y! velocity (- (.-y velocity)))
          (< (-> this .-position .-x) (-> other .-position .-x))
          (.-x! velocity (- (.-x velocity)))
          (> (-> this .-position .-x) (-> other .-position .-x))
          (.-x! velocity (- (.-x velocity))))
        (.setFrom velocity (.* velocity difficulty-modifier)))
      :else (println "collision with" other))
    nil))

(defn ball [velocity position radius difficulty-modifier]
  (doto (Ball velocity difficulty-modifier) ; 👈
    (.-position! position)
    (.-radius! radius)))

(deftype Bat [corner-radius]
  :extends (components/PositionComponent
             .anchor components/Anchor.center
             .children [(collisions/RectangleHitbox)])
  (render [this canvas]
    (.render ^super this canvas)
    (.drawRRect canvas
      (m/RRect.fromRectAndRadius
        (.& m/Offset.zero (let [{:flds [x y]} (.-size this)] (m/Size x y))) ; .toSize is provided by an extension
        corner-radius)
      (doto (m/Paint)
        (.-color! (m/Color 0xff1e6091))
        (.-style! (m/PaintingStyle.fill)))))
  Bat
  (moveBy [this dx]
    (.add this
      (effects/MoveToEffect
        (game/Vector2
          (.clamp (+ (-> this .-position .-x) dx) 0 (-> this .-game .-width))
          (-> this .-position .-y))
        (effects/EffectController .duration 0.1))))
  ^:mixin events/DragCallbacks
  (onDragUpdate [this event]
    (.onDragUpdate ^super this event)
    (.-x! (.-position this)
      (.clamp (+ (-> this .-position .-x) (-> event .-localDelta .-x)) 0 ^double (-> this .-game .-width)))
    nil)
  ^:mixin components/HasGameReference)

(defn bat [corner-radius position size]
  (doto (Bat corner-radius)
    (.-position! position)
    (.-size! size)))

; 👇
(deftype Brick []
  :extends (components/RectangleComponent
             .anchor components/Anchor.center
             .children [(collisions/RectangleHitbox)])
  ^:mixin collisions/CollisionCallbacks
  (onCollisionStart [this intersection-points other]
    (.onCollisionStart ^super this intersection-points other)
    (.removeFromParent this)
    (let [{:flds [children] :as world} (-> this .-game .-world)]
      (when (= 1 (count (#/(.query Brick) children)))
        (doto world
          (.removeAll (#/(.query Ball) children))
          (.removeAll (#/(.query Ball) children)))))
    nil)
  ^:mixin components/HasGameReference)

; 👇
(defn brick [position color]
  (doto (Brick)
    (.-position! position)
    (.-size! (game/Vector2 brick-width brick-height))
    (.-paint! (doto (m/Paint)
                (.-color! color)
                (.-style! m/PaintingStyle.fill)))))

(deftype BrickBreaker []
  :extends (game/FlameGame
             .camera (components/CameraComponent.withFixedResolution
                       .width game-width
                       .height game-height))
  (onLoad [this]
    (.onLoad  ^super this)
    (-> this .-camera .-viewfinder (.-anchor! components/Anchor.topLeft))
    (doto (.-world this)
      (.add (PlayArea))
      (.add (ball
              (game/Vector2 (* (- (rand) 0.5) (.-width this)) (* 0.2 (.-height this)))
              (. (.-size this) / 2)
              ball-radius
              difficulty-modifier #_👈))
      (.add (bat
              (m/Radius.circular (/ ball-radius 2))
              (game/Vector2 (/ (.-width this) 2) (* (.-height this) 0.95))
              (game/Vector2 bat-width bat-height)))
      ; 👇
      ; skipping await as I don't see why we should await addAll and not all other adds
      (.addAll (for [i (range (count brick-colors))
                     j (range 5)]
                 (brick
                   (game/Vector2
                     (+ (* (+ 0.5 i) brick-width) (* (inc i) brick-gutter))
                     (+ (* (+ j 3.0) brick-height) (* j brick-gutter)))
                   (nth brick-colors i)))))
    (.-debugMode! this true))
  ^:mixin game/HasCollisionDetection
  ^:mixin events/KeyboardEvents
  (onKeyEvent [this event keys-pressed]
    (.onKeyEvent ^super this event keys-pressed)
    (condp = (.-logicalKey event)
      services/LogicalKeyboardKey.arrowLeft
      (-> this .-world .-children (#/(.query Bat) #_🙄) .-first (.moveBy (- bat-step)) )
      services/LogicalKeyboardKey.arrowRight
      (-> this .-world .-children (#/(.query Bat) #_🙄) .-first (.moveBy bat-step) ))
    m/KeyEventResult.handled)
  BrickBreaker
  (^:getter width [this] (-> this .-size .-x))
  (^:getter height [this] (-> this .-size .-y)))

(defn main []
  (f/run
    (game/GameWidget .game (BrickBreaker))))

Enregistrement de l’écran 2024-10-02 à 16.24.21.gif

Skipping next steps

Next steps of the tutorial are more Flutter-related so I'm stopping there.

Tying loose ends

The compiler complains about 5 dynamic warnings:

DYNAMIC WARNING: can't resolve member width on target type FlameGame of library package:flame/src/game/flame_game.dart  (no source location)
DYNAMIC WARNING: can't resolve member height on target type FlameGame of library package:flame/src/game/flame_game.dart  (no source location)
DYNAMIC WARNING: can't resolve member width on target type FlameGame of library package:flame/src/game/flame_game.dart  at line: 71, column: 9, file: brickbreaker/main.cljd
DYNAMIC WARNING: can't resolve member width on target type FlameGame of library package:flame/src/game/flame_game.dart  at line: 115, column: 11, file: brickbreaker/main.cljd
DYNAMIC WARNING: can't resolve member width on target type FlameGame of library package:flame/src/game/flame_game.dart  at line: 122, column: 7, file: brickbreaker/main.cljd

Sure enough they are tied to the HasGameReference we left unparametrized. Trying to change it to #/(HasGameReference BrickBreaker), I hit a circularity issue between types. It can be fixed by declaring BrickBreaker twice: a first time without any implementation and a second time (the existing one) at the end.

(ns brickbreaker.main
  (:require
   ["package:flame/game.dart" :as game]
   ["package:flutter/material.dart" :as m]
   ["package:flutter/services.dart" :as services]
   ["dart:async" :as async]
   ["package:flame/collisions.dart" :as collisions]
   ["package:flame/components.dart" :as components]
   ["package:flame/effects.dart" :as effects]
   ["package:flame/events.dart" :as events]
   [cljd.flutter :as f]))

(def game-width 820.0)
(def game-height 1600.0)
(def ball-radius (* game-width 0.02))
(def bat-width (* game-width 0.2))
(def bat-height (* ball-radius 2))
(def bat-step (* game-width 0.05))
(def brick-colors [(m/Color 0xfff94144),
                   (m/Color 0xfff3722c),
                   (m/Color 0xfff8961e),
                   (m/Color 0xfff9844a),
                   (m/Color 0xfff9c74f),
                   (m/Color 0xff90be6d),
                   (m/Color 0xff43aa8b),
                   (m/Color 0xff4d908e),
                   (m/Color 0xff277da1),
                   (m/Color 0xff577590)])
(def brick-gutter (* game-width 0.015))
(def brick-width (/ (- game-width (* brick-gutter (inc (count brick-colors))))
                   (count brick-colors)))
(def brick-height (* game-height 0.03))
(def difficulty-modifier 1.03)

; 👇
; pre-declaration of BrickBreaker to fix the circularity problem
(deftype BrickBreaker []
  :extends game/FlameGame
  ^:mixin game/HasCollisionDetection
  ^:mixin events/KeyboardEvents
  BrickBreaker
  (^:getter width [this] 42)
  (^:getter height [this] 42))

(deftype PlayArea []
  :extends (components/RectangleComponent
             .paint (doto (m/Paint) (.-color! (m/Color 0xfff2e8cf)))
             .children [(collisions/RectangleHitbox)])
  (onLoad [this]
    (.onLoad ^super this)
    (.-size! this (game/Vector2 (-> this .-game .-width) (-> this .-game .-height)))) ; 👈
  ^:mixin #/(components/HasGameReference BrickBreaker)) ; 👈

(deftype Ball [^game/Vector2 velocity difficulty-modifier]
  :extends (components/CircleComponent
             .anchor components/Anchor.center
             .paint (doto (m/Paint)
                      (.-color! (m/Color 0xff1e6091))
                      (.-style! m/PaintingStyle.fill))
             .children [(collisions/CircleHitbox)])
  (update [this dt]
    (.update ^super this dt)
    (.-position! this (.+ (.-position this) (.* velocity dt)))
    nil)
  ^:mixin #/(components/HasGameReference BrickBreaker)
  ^:mixin collisions/CollisionCallbacks
  (onCollisionStart [this intersection-points other]
    (.onCollisionStart ^super this intersection-points other)
    (cond
      (instance? PlayArea other)
      (cond
        (<= (-> intersection-points .-first .-y) 0) (.-y! velocity (- (.-y velocity)))
        (<= (-> intersection-points .-first .-x) 0) (.-x! velocity (- (.-x velocity)))
        (<= (-> this .-game .-width) (-> intersection-points .-first .-x)) (.-x! velocity (- (.-x velocity)))
        (<= (-> this .-game .-height) (-> intersection-points .-first .-y))
        (.add this (effects/RemoveEffect .delay 0.35)))
      (instance? Bat other)
      (do
        (.-y! velocity (- (.-y velocity)))
        (.-x! velocity (+ (.-x velocity)
                         (* (- (-> this .-position .-x) (-> other .-position .-x))
                           (-> other .-size .-x /)
                           (-> this .-game .-width)
                           0.3))))
      (instance? Brick other)
      (do
        (cond
          (< (-> this .-position .-y) (- (-> other .-position .-y) (/ (-> other .-size .-y) 2)))
          (.-y! velocity (- (.-y velocity)))
          (> (-> this .-position .-y) (+ (-> other .-position .-y) (/ (-> other .-size .-y) 2)))
          (.-y! velocity (- (.-y velocity)))
          (< (-> this .-position .-x) (-> other .-position .-x))
          (.-x! velocity (- (.-x velocity)))
          (> (-> this .-position .-x) (-> other .-position .-x))
          (.-x! velocity (- (.-x velocity))))
        (.setFrom velocity (.* velocity difficulty-modifier)))
      :else (println "collision with" other))
    nil))

(defn ball [velocity position radius difficulty-modifier]
  (doto (Ball velocity difficulty-modifier) ; 👈
    (.-position! position)
    (.-radius! radius)))

(deftype Bat [corner-radius]
  :extends (components/PositionComponent
             .anchor components/Anchor.center
             .children [(collisions/RectangleHitbox)])
  (render [this canvas]
    (.render ^super this canvas)
    (.drawRRect canvas
      (m/RRect.fromRectAndRadius
        (.& m/Offset.zero (let [{:flds [x y]} (.-size this)] (m/Size x y))) ; .toSize is provided by an extension
        corner-radius)
      (doto (m/Paint)
        (.-color! (m/Color 0xff1e6091))
        (.-style! (m/PaintingStyle.fill)))))
  Bat
  (moveBy [this dx]
    (.add this
      (effects/MoveToEffect
        (game/Vector2
          (.clamp (+ (-> this .-position .-x) dx) 0 (-> this .-game .-width))
          (-> this .-position .-y))
        (effects/EffectController .duration 0.1))))
  ^:mixin events/DragCallbacks
  (onDragUpdate [this event]
    (.onDragUpdate ^super this event)
    (.-x! (.-position this)
      (.clamp (+ (-> this .-position .-x) (-> event .-localDelta .-x)) 0 ^double (-> this .-game .-width)))
    nil)
  ^:mixin #/(components/HasGameReference BrickBreaker)) ; 👈

(defn bat [corner-radius position size]
  (doto (Bat corner-radius)
    (.-position! position)
    (.-size! size)))

(deftype Brick []
  :extends (components/RectangleComponent
             .anchor components/Anchor.center
             .children [(collisions/RectangleHitbox)])
  ^:mixin collisions/CollisionCallbacks
  (onCollisionStart [this intersection-points other]
    (.onCollisionStart ^super this intersection-points other)
    (.removeFromParent this)
    (let [{:flds [children] :as world} (-> this .-game .-world)]
      (when (= 1 (count (#/(.query Brick) children)))
        (doto world
          (.removeAll (#/(.query Ball) children))
          (.removeAll (#/(.query Ball) children)))))
    nil)
  ^:mixin #/(components/HasGameReference BrickBreaker)) ; 👈

(defn brick [position color]
  (doto (Brick)
    (.-position! position)
    (.-size! (game/Vector2 brick-width brick-height))
    (.-paint! (doto (m/Paint)
                (.-color! color)
                (.-style! m/PaintingStyle.fill)))))

(deftype BrickBreaker []
  :extends (game/FlameGame
             .camera (components/CameraComponent.withFixedResolution
                       .width game-width
                       .height game-height))
  (onLoad [this]
    (.onLoad  ^super this)
    (-> this .-camera .-viewfinder (.-anchor! components/Anchor.topLeft))
    (doto (.-world this)
      (.add (PlayArea))
      (.add (ball
              (game/Vector2 (* (- (rand) 0.5) (.-width this)) (* 0.2 (.-height this)))
              (. (.-size this) / 2)
              ball-radius
              difficulty-modifier))
      (.add (bat
              (m/Radius.circular (/ ball-radius 2))
              (game/Vector2 (/ (.-width this) 2) (* (.-height this) 0.95))
              (game/Vector2 bat-width bat-height)))
      ; skipping await as I don't see why we should await addAll and not all other adds
      (.addAll (for [i (range (count brick-colors))
                     j (range 5)]
                 (brick
                   (game/Vector2
                     (+ (* (+ 0.5 i) brick-width) (* (inc i) brick-gutter))
                     (+ (* (+ j 3.0) brick-height) (* j brick-gutter)))
                   (nth brick-colors i)))))
   #_ (.-debugMode! this true)) ; 👈
  ^:mixin game/HasCollisionDetection
  ^:mixin events/KeyboardEvents
  (onKeyEvent [this event keys-pressed]
    (.onKeyEvent ^super this event keys-pressed)
    (condp = (.-logicalKey event)
      services/LogicalKeyboardKey.arrowLeft
      (-> this .-world .-children (#/(.query Bat) #_🙄) .-first (.moveBy (- bat-step)) )
      services/LogicalKeyboardKey.arrowRight
      (-> this .-world .-children (#/(.query Bat) #_🙄) .-first (.moveBy bat-step) ))
    m/KeyEventResult.handled)
  BrickBreaker
  (^:getter width [this] (-> this .-size .-x))
  (^:getter height [this] (-> this .-size .-y)))

(defn main []
  (f/run
    (game/GameWidget .game (BrickBreaker))))

Oh, I also removed the debug overlay, much nicer:
image.png

Final thoughts

Following this tutorial brought us and the compiler out of our comfort zone. We fixed three fine interop bugs along the way (did you know that a getter/setter pair may disagree on the returned/expected type? Did you know that the super class of a class is not the class specified after extends when mixins are applied?).

At about 200 lines of code it's not bad.

The gameplay is meh but it's ok it's a tutorial I guess.

  • keyboard input as implemented is dependent on keyboard repeats, thus you don't have smooth continuous movement of the bat. I didn't find a builtin way to get notified each frame about already pressed keys. So you would have to roll your own.
  • no continuous movement means no fine control on how the bat velocity combines to the ball velocity on impact.

The code above is a straight port of the Dart version without knowing anything about Flame. Things could be made more Clojure friendly by introducing more generic components. However they are dependencies on types for example in the way query uses a cache indexed by types. So more generic components would call for other filtering methods.

There also is mutation and nesting all over the place.

Making a good helper/wrapper would be an interesting challenge.

A good first step would be to reimplement this breakout game with a more data-oriented and less type-centric approach and see where the impedance mismatches occur.

You just read issue #7 of Curiosities by Tensegritics. You can also browse the full archives of this newsletter.

Share on Twitter Share on LinkedIn Share on Hacker News Share on Reddit Share on Mastodon
This email brought to you by Buttondown, the easiest way to start and grow your newsletter.