Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Go gamedev patterns

Go gamedev patterns

Iskander (Alex) Sharipov

January 09, 2023
Tweet

More Decks by Iskander (Alex) Sharipov

Other Decks in Programming

Transcript

  1. Prerequisite ✅ Go 1.18 (we need generics) ✅ Ebitengine ✅

    Legendary tolerance for my English Please ⭐ both Ebitengine and Godot projects :3 2
  2. About $username • Compiler engineer during the day • Game

    developer during the night • I also stream random indie games from itch io • Played video games since ~2000 I worked with Game Maker, Godot and Ebiten. I also tried Defold and Phaser. 3
  3. 5

  4. 6

  5. 7

  6. 8

  7. 9

  8. 10

  9. 11

  10. 12

  11. 13

  12. 14

  13. Ebitengine vs Godot impressions Cons: • It’s much harder to

    become productive • Much smaller community 19
  14. Ebitengine vs Godot impressions Cons: • It’s much harder to

    become productive • Much smaller community • Ebiten provides no “framework” to shape your game 20
  15. Ebitengine vs Godot impressions Cons: • It’s much harder to

    become productive • Much smaller community • Ebiten provides no “framework” to shape your game • The ecosystem is not mature enough 21
  16. Ebitengine vs Godot impressions Pros: • Go as a first-class

    language • Full go toolchain benefits: tests, benchmarks, profiling 24
  17. Ebitengine vs Godot impressions Pros: • Go as a first-class

    language • Full go toolchain benefits: tests, benchmarks, profiling • You can help the ecosystem to get better 25
  18. You need more than just Ebitengine Ebitengine Input Nodes/ECS Res.

    manager Math utils Collisions Slots/signals UI Layers Camera Tiles Scripting Prem ium content! $$$ 27
  19. Some core ideas • Port the code and ideas from

    Godot (C++ -> Go) • Start from the simpler, small games 29
  20. Some core ideas • Port the code and ideas from

    Godot (C++ -> Go) • Start from the simpler, small games • Browse awesome-ebiten for packages and inspiration 30
  21. Why many Go gamedev libs are not mature? In my

    opinion: • They’re created to solve a specific task at the moment of the game creation • Most lib authors are interested in creating games, not libraries 31
  22. Some other issues • Libraries don’t play well together •

    It’s hard to become the #1 library 32
  23. Some other issues • Libraries don’t play well together •

    It’s hard to become the #1 library 33
  24. Vec2 use cases • Velocity, force and other vector-like units

    • Simple positions, points in space (X, Y) 36
  25. Vec2 use cases • Velocity, force and other vector-like units

    • Simple positions, points in space (X, Y) • Normalized (unit) vectors for directions 37
  26. Why float64 • Float literals in Go imply float64 type

    • Go math library works with float64 • Most ebiten APIs work with float64 Only GPU-related things may require float32 39
  27. Why float64 • Float literals in Go imply float64 type

    • Go math library works with float64 • Most ebiten APIs work with float64 Only GPU-related things may require float32 40
  28. Why float64 • Float literals in Go imply float64 type

    • Go math library works with float64 • Most ebiten APIs work with float64 Only GPU-related things may require float32 41
  29. Vec2 representation kvartborg/vector uses slices to represent vectors. This is

    not efficient because most vectors in 2D game have exactly 2 components. Godot is open-source, you can roll the implementation based on its source code. 42
  30. Vec2 representation kvartborg/vector uses slices to represent vectors. This is

    not efficient because most vectors in 2D game have exactly 2 components. Godot is open-source, you can roll the implementation based on its source code. 43
  31. type Button struct { Pos Vec2 } type Container struct

    { Pos Vec2 } How about simple vectors for UI elements position? 46
  32. type Tank struct { Pos Vec2 } type Turret struct

    { Pos Vec2 } And for the other game objects as well... 48
  33. Position requirements • Small size • Can express a position

    with offset • Can automatically follow other position (parent) Some positions are still going to be Vec2 50
  34. Position requirements • Small size • Can express a position

    with offset • Can automatically follow other position (parent) Some positions are still going to be Vec2 51
  35. Position requirements • Small size • Can express a position

    with offset • Can automatically follow other position (parent) Some positions are still going to be Vec2 52
  36. Pointers for the (one-sided) data binding • Data source: a

    value owner, read+write • Pointer to data: read-only We can combine both to get a more flexible concept. 53
  37. type Pos struct { Base *Vec2 // The position origin

    or (0, 0) if nil Offset Vec2 // The offset in relation to the origin } func (p Pos) Resolve() Vec2 { if p.Base == nil { return p.Offset } return p.Base.Add(p.Offset) } 54
  38. type Pos struct { Base *Vec2 // The position origin

    or (0, 0) if nil Offset Vec2 // The offset in relation to the origin } func (p Pos) Resolve() Vec2 { if p.Base == nil { return p.Offset } return p.Base.Add(p.Offset) } 55
  39. func (p Pos) WithOffset(ox, oy float64) Pos { ox +=

    p.Offset.X oy += p.Offset.Y return Pos{ Base: p.Base, Offset: Vec2{X: ox, Y: oy}, } } 60
  40. Why signals & slots? • Reduces objects coupling • An

    elegant event listener solution for Go 63
  41. Why signals & slots? • Reduces objects coupling • An

    elegant event listener solution for Go • Familiar concept (Godot, Phaser, Qt, …) 64
  42. In Go terms • Signal = a field inside a

    struct • Slot = a function bound to a signal 66
  43. In Go terms • Signal = a field inside a

    struct • Slot = a function bound to a signal • Disconnect = remove bound function 67
  44. In Go terms • Signal = a field inside a

    struct • Slot = a function bound to a signal • Disconnect = remove bound function • Emit = call all bound functions 68
  45. type Event[T any] struct { handlers []eventHandler[T] } type eventHandler[T

    any] struct { c connection f func(T) } type connection interface { IsDisposed() bool } 69
  46. type Event[T any] struct { handlers []eventHandler[T] } type eventHandler[T

    any] struct { c connection f func(T) } type connection interface { IsDisposed() bool } 70
  47. type Event[T any] struct { handlers []eventHandler[T] } type eventHandler[T

    any] struct { c connection f func(T) } type connection interface { IsDisposed() bool } 71
  48. func (e *Event[T]) Connect(conn connection, slot func(T)) { e.handlers =

    append(e.handlers, eventHandler[T]{ c: conn, f: slot, }) } 72
  49. func (e *Event[T]) Disconnect(conn connection) { for i, h :=

    range e.handlers { if h.c == conn { e.handlers[i].c = theRemovedConnection break } } } 73
  50. type removedConnection struct{} func (r *removedConnection) IsDisposed() bool { return

    true } var theRemovedConnection = &removedConnection{} 74
  51. func (e *Event[T]) Emit(arg T) { handlers := e.handlers[:0] for

    _, h := range e.handlers { if h.c != nil && h.c.IsDisposed() { continue // effectively removes the handler } h.f(arg) handlers = append(handlers, h) } e.handlers = handlers } 75
  52. func ConnectOneShot[T any](e *Event[T], c connection, f func(T)) { oneshot

    := &oneshotConnector[T]{conn: c} e.Connect(oneshot, func(arg T) { oneshot.fired = true f(arg) }) } 81
  53. type oneshotConnector[T any] struct { conn connection fired bool }

    func (c *oneshotConnector[T]) IsDisposed() bool { if c.fired { return true } return c.conn != nil && c.conn.IsDisposed() } 82
  54. Implementation properties • Tiny overhead and no allocs with 0

    connections • Statically typed signatures without interface{} 85
  55. Implementation properties • Tiny overhead and no allocs with 0

    connections • Statically typed signatures without interface{} • Fast connect+emit 86
  56. Implementation properties • Tiny overhead and no allocs with 0

    connections • Statically typed signatures without interface{} • Fast connect+emit • Not thread-safe 87
  57. Why tuples? • Make things like Event[T] viable • Abstract

    away the arity in generic code Go doesn’t have variadic templates, so we need a way to overcome this limitation 89
  58. Why tuples? • Make things like Event[T] viable • Abstract

    away the arity in generic code Go doesn’t have variadic templates, so we need a way to overcome this limitation 90
  59. type Value2[T1 any, T2 any] struct { First T1 Second

    T2 } func New2[T1 any, T2 any](a T1, b T2) Value2[T1, T2] { return Value2[T1, T2]{a, b} } 91
  60. type Value3[T1 any, T2 any, T3 any] struct { First

    T1 Second T2 Third T3 } func New3[T1 any, T2 any, T3 any](...) Value2[T1, T2, T3] { return Value3[T1, T2, T3]{a, b, c} } 92
  61. func (v Value2[T1, T2]) Fields() (T1, T2) { return v.First,

    v.Second } func (v Value3[T1, T2, T3]) Fields() (T1, T2, T3) { return v.First, v.Second, v.Third } 93
  62. Keymap requirements • Input-device agnostic • Supports N-to-M action-key mapping

    • Multi-device support (gamepads, etc.) • Can be conveniently reconfigured at run time 99
  63. Action can be associated with multiple input events Key W

    Arrow Up ActionForward D-Pad Up 103
  64. Every input handler has its ID (useful for gamepads) Player

    2 Gamepad 2 Player 1 Gamepad 1 Handler{ID:1} Handler{ID:0} 104
  65. All handlers are connected to some shared “global input system”

    InputSystem Handler{ID:1} Handler{ID:0} 105
  66. type Handler struct { id int keymap Keymap sys *System

    } func (sys *System) NewHandler(id int, k Keymap) *Handler { return &Handler{ id: id, keymap: k, sys: sys, } } 106
  67. type Action uint32 type Keymap map[Action][]Key type Key struct {

    code int kind keyKind } type keyKind uint8 const ( keyKeyboard keyKind = iota keyGamepad keyMouse ) 108
  68. var KeyW = Key{ code: int(ebiten.KeyW), kind: keyKeyboard, } var

    KeyGamepadUp = Key{ code: int(ebiten.StandardGamepadButtonLeftTop), kind: keyGamepad, } 109
  69. var keymap = Keymap{ ActionForward: { KeyW, KeyArrowUp, KeyGamepadUp, },

    } inputHandler := isys.NewHandler(0, keymap) 111
  70. func (h *Handler) ActionIsJustPressed(action Action) bool { keys, ok :=

    h.keymap[action] if !ok { return false } for _, k := range keys { if h.keyIsJustPressed(k) { return true } } return false } 112
  71. func (h *Handler) keyIsJustPressed(key Key) bool { switch key.kind {

    case keyGamepad: return h.gamepadKeyIsPressed(key) case keyMouse: return h.mouseKeyIsJustPressed(key) default: return h.keyboardKeyIsJustPressed(key) } } 113
  72. Resource loader requirements • Convenient to use to get resources

    • Efficient resource lookup and caching 118
  73. Resource loader requirements • Convenient to use to get resources

    • Efficient resource lookup and caching • API that can prevent some mistyped keys, etc. 119
  74. Resource loader requirements • Convenient to use to get resources

    • Efficient resource lookup and caching • API that can prevent some mistyped keys, etc. • Can preload resources (+ optionally unload them) 120
  75. App enumerates all its resources as enums const ( ImageNone

    resource.ImageID = iota ImageTank ImageTurret ) 122
  76. ...and then associates metadata with them imageResources := map[resource.ImageID]resource.ImageInfo{ ImageTank:

    {Path: "sprites/tank.png"}, ImageTurret: {Path: "sprites/turret.png"}, } 123
  77. Resource lookup 1. Find associated metadata a. Return from cache,

    if possible 2. Using path info, use asset finder to get raw data 3. Decode data into requested resource 4. Cache the result 5. Return the result 125
  78. Asset finder can work with embed data loader.OpenAsset = func(p

    string) io.ReadCloser { f, err := gameAssets.Open("_assets/" + p) if err != nil { // handle error } return f } 126
  79. Resource preload Just call Get() method on the resource needed

    and ignore the results. The next time it’ll be returned from cache. 128
  80. Why bother and avoid global state? • Easier to test

    your code • Can substitute dependencies easier 131
  81. Why bother and avoid global state? • Easier to test

    your code • Can substitute dependencies easier • Scene transitions become trivial 132
  82. main() runGame(state) newMenuController(state) newBattleController(state) newPlayerTank(tank, state) Creates the gameState Accepts

    gameState as argument All scene controllers hold the gameState On scene change, gameState is passed The scene controller passes it to objects 134
  83. // This is just a simplified example. func main() {

    p1keymap, p2keymap := readKeymaps() state := newGameState() state.player1input = input.NewHandler(0, p1keymap) state.player2input = input.NewHandler(1, p2keymap) runGame(state) } 136
  84. 138

  85. Tiled use cases Tiled can be your graphical level editor!

    • Object layers to deploy game entities • Tile layers to draw the level landscape • Can also be used to prototype the UI layouts • Tilesets for auto-tiling 139
  86. 140

  87. Tiled + modders Non-programmers can use tiled to create levels

    for your game. A special triggers tileset can be used to apply visual programming, leveraging the Tiled limitations. 141
  88. Things I still try to figure out • Layers and

    rendering order abstractions • Viewports and cameras with Ebitengine • Smooth rendering of geometrical shapes • Etc, etc. 143
  89. Additional resources • sfxr (or jsfxr) - generate game sound

    effects • ccmixter - find CC licensed music for your games I want to try using neural network generated images and music in one of my next games. :) 145