GameLisp review: a new language for writing games in Rust



A programmer who signs himself with the pseudonym Fleabit has been developing his programming language for six months . The question immediately arises: another language? What for?



Here are his arguments:



  • โ€“ , , , . , garbage collection .
  • Rust : , , โ€“ enum- ; pattern matching ; , ; .. , Rust : ยซ , ยป; ; /, , .
  • JavaScript, Lua, Python Ruby; Rust โ€“ , - , . , garbage collector, , โ€“ , GC , . GameLisp โ€“ , .
  • GameLisp, โ€“ , , . enum- Rust, , . "" , .


First of all, the simplicity of syntax and simplicity of the interpreter are taken from Lisp in GameLisp: the implementation of GameLisp together with the "standard library" now takes 36 KLOC, compared, for example, with 455 KLOC in Python. On the other hand, compared to regular Lisp, GameLisp has no lists and much less focus on functional programming and immutable data; instead, like most scripting languages, GameLisp focuses on imperative, object-oriented programming.

The Lisp-based syntax can be overwhelming, but you quickly get used to writing (.print console (+ 2 2)), etc. instead of console.print (2 + 2). This syntax is much simpler and more flexible than in familiar scripting languages: the comma is considered a whitespace character, and can be used to improve readability anywhere in the code; instead of two types of brackets {} (), only round brackets are used; most ASCII characters can be used in characters, so I ~ <3 ~ Lisp! ~ ^ _ ^ is a valid name for a function or variable; Not needed; to separate operations, etc. I can say that without any past experience with Lisp, in just a couple of evenings I was able to rewrite the classic NIBBLES.BAS on GameLisp: http://atari.ruvds.com/nibbles.html



All there is in the GameLisp "standard library" for I / O is a prn function for printing to stdout; no keyboard / mouse work, no files, no graphics, no sound. It is assumed that the GameLisp user himself implements in Rust all those interface tools that are relevant specifically in his project. As an example of such a binding, a minimalistic engine for browser games is posted on https://gamelisp.rs/playground/ using wasm-bindgenwhich provides the GameLisp code with the functions play: down ?, play: pressed ?, play: released ?, play: mouse-x, play: mouse-y, play: fill, and play: draw. My port of Nibbles uses the same engine - I just added a function to it to play sound. It is interesting to compare the sizes: the original NIBBLES.BAS was 24 KB; my port on GameLisp is 9KB; The WebAssembly file with the compiled Rust runtime, the GameLisp interpreter, and the game code is 2.5 MB, and it also comes with an 11 KB JavaScript binding generated by wasm-bindgen.



Together with a minimalistic engine at https://gamelisp.rs/playground/Added GameLisp implementations of three classic games: pong, tetris and sapper. Tetris and Minesweeper are bigger and more complex than my port of Nibbles, and there's a lot to learn from their code.



To demonstrate the capabilities of GameLisp, I have chosen two examples; the first concerns macros. In NIBBLES.BAS, the levels are specified by the SELECT CASE line block with nested loops:



SELECT CASE curLevel
CASE 1
    sammy(1).row = 25: sammy(2).row = 25
    sammy(1).col = 50: sammy(2).col = 30
    sammy(1).direction = 4: sammy(2).direction = 3

CASE 2
    FOR i = 20 TO 60
        Set 25, i, colorTable(3)
    NEXT i
    sammy(1).row = 7: sammy(2).row = 43
    sammy(1).col = 60: sammy(2).col = 20
    sammy(1).direction = 3: sammy(2).direction = 4

CASE 3
    FOR i = 10 TO 40
        Set i, 20, colorTable(3)
        Set i, 60, colorTable(3)
    NEXT i
    sammy(1).row = 25: sammy(2).row = 25
    sammy(1).col = 50: sammy(2).col = 30
    sammy(1).direction = 1: sammy(2).direction = 2

...
      
      





All these loops have a similar structure, which can be included in a macro:



(let-macro set-walls (range ..walls)
  `(do ~..(map (fn1
    `(forni (i ~..range) (set-wall ~.._))) walls)))

      
      





With this macro, the description of all levels is reduced by four, and becomes as close as possible to a declarative JSON-like description:



(match @level
  (1 (set-locations '(25 50 right) '(25 30 left)))
  (2 (set-walls (20 60) (25 i))
     (set-locations '(7 60 left) '(43 20 right)))
  (3 (set-walls (10 40) (i 20) (i 60))
     (set-locations '(25 50 up) '(25 30 down)))
  ...
      
      





In a language without macros - for example, in JavaScript - a similar implementation would obscure the entire description of levels with lambdas:



switch (level) {
case 1: setLocations([25, 50, "right"], [25, 30, "left"]); break;
case 2: setWalls([20, 60], i => [25, i]);
        setLocations([7, 60, "left"], [43, 20, "right"]); break;
case 3: setWalls([10, 40], i => [i, 20], i => [i, 60]);
        setLocations([25, 50, "up"], [25, 30, "down"]); break;
...
      
      





This example clearly shows how the JavaScript code is overloaded with various punctuation and function words, which you can do without.

My second example is about state machines. My implementation of the game has the following structure:



(defclass Game

  ...

  (fsm
    (state Playing
      (field blink-rate (Rate 0.2))
      (field blink-on)
      (field move-rate (Rate 0.3))
      (field target)
      (field prize 1)

      (state Paused
        (init-state ()
          (@center "*** PAUSED ***" 0))
        (wrap Playing:update (dt)
          (when (play:released? 'p)
            (@center "    LEVEL {@level}    " 0)
            (@disab! 'Paused))))

      (met update (dt)
        ...

        (when (play:released? 'p)
          (@enab! 'Paused) (return))

        ...

        ; Move the snakes
        (.at @move-rate dt (fn0 
          (for snake in @snakes (when (> [snake 'lives] 0)
            (let position (clone [[snake 'body] 0]))

            ...

            ; If player runs into any point, he dies
            (when (@occupied? position)
              (play:sound 'die)
              (dec! [snake 'lives])
              (dec! [snake 'score] 10)
              (if (all? (fn1 (== 0 [_ 'lives])) @snakes)
                (@enab! 'Game-Over)
                (@enab! 'Erase-Snake snake))
              (return))

        ...

    (state Game-Over
      (init-state ()
        (play:fill ..(@screen-coords 10 (-> @grid-width (/ 2) (- 16))) ..(@screen-coords 7 32) 255 255 255)
        (play:fill ..(@screen-coords 11 (-> @grid-width (/ 2) (- 15))) ..(@screen-coords 5 30) ..@background)
        (@center "G A M E   O V E R" 13))
      (met update (dt)))))
      
      





On every frame (when called from window.requestAnimationFrame), the game engine calls the Game.update method. Inside the Game class, an automaton is defined from the Init-Level, Playing, Erase-Snake, Game-Over states, each of which defines the update method in its own way. In the Playing state, five private fields are defined that cannot be accessed from other states. In addition, the Playing state has a nested Paused state, i.e. the game can be in either the Playing state or the Playing: Paused state. The Paused state constructor prints the corresponding line on the screen each time it transitions to this state; the update method, in this state, checks to see if the P key has been pressed again, and if pressed and released, exits the Paused state, returning to the "plain" Playing state. The Playing state update method handles keystrokes,calculates the new position of the players, and if one of them crashed into the wall, then it goes either to the Game-Over state or to the Erase-Snake state. The constructor of the Erase-Snake state is interesting in that it takes as a parameter a link to a snake, which must be beautifully erased before restarting the level. Finally, for the Game-Over state, the constructor displays a corresponding message on the screen, and the update method is empty, which means that no matter what keys are pressed, nothing new will be drawn on the screen, and it is impossible to exit this state.Finally, for the Game-Over state, the constructor displays a corresponding message on the screen, and the update method is empty, which means that no matter what keys are pressed, nothing new will be drawn on the screen, and it is impossible to exit this state.Finally, for the Game-Over state, the constructor displays a corresponding message on the screen, and the update method is empty, which means that no matter what keys are pressed, nothing new will be drawn on the screen, and it is impossible to exit this state.



The game could be implemented in a similar way in a classic scripting language: the Game class would have nested InitLevel, Playing, EraseSnake, GameOver classes, there would be a currentState field, and the Game.update method would delegate the call to currentState.update. Inside the Playing class would be a nested Paused class, and the Playing.update method would in turn delegate the call to the sub-object. The standard library macros hide the automatic generation of currentState fields and delegating methods so that the game developer sees meaningful implementation of states, rather than their boilerplate.



Instead of a state machine, Nibbles could be implemented as a loop:



while (lives>0) {
  InitLevel;
  while (prize<10) {
    Playing;
    if (dies) {
      EraseSnake;
      break;
    }
  }
}
GameOver;

      
      





This is how the original QBasic game was implemented. For a browser engine, such a loop would be wrapped in a generator with yield after rendering each frame, and Game.update would consist of a call to iter-next! .. I preferred the implementation as an automaton for two reasons: first, this is how the Tetris implementation works. which the author of GameLisp cites as an example; and secondly, there is nothing unusual about the generators in GameLisp compared to other scripting languages. The main purpose for automata is to implement the states of game characters (waiting, attacking, running away, etc.), which is impossible by means of a loop inside the generator. An additional argument in favor of automata is the isolation of data related to each of the states from each other.






All Articles