Browse Source

feat: Adding ECAL websocket support

Matthias Ladkau 3 years ago
parent
commit
b45d49d6c1
76 changed files with 2449 additions and 283 deletions
  1. 1 1
      .gitignore
  2. 3 0
      api/v1/cluster_test.go
  3. 185 0
      api/v1/ecal-sock.go
  4. 180 0
      api/v1/ecal-sock_test.go
  5. 3 3
      api/v1/ecal.go
  6. 2 1
      api/v1/ecal_test.go
  7. 0 2
      api/v1/graphql-subscriptions_test.go
  8. 3 2
      api/v1/rest.go
  9. 2 0
      config/config.go
  10. 15 3
      ecal.md
  11. 84 8
      ecal/interpreter.go
  12. 5 5
      ecal/interpreter_test.go
  13. 98 0
      ecal/websocket.go
  14. 133 0
      ecal/websocket_test.go
  15. 14 0
      examples/game/doc/frontend.md
  16. 34 0
      examples/game/eliasdb.config.json
  17. 5 0
      examples/game/get_score.sh
  18. 0 7
      examples/game/get_state.sh
  19. 33 0
      examples/game/res/eliasdb.config.json
  20. 11 0
      examples/game/res/frontend/assets/asset_license.txt
  21. BIN
      examples/game/res/frontend/assets/asteroid_001.png
  22. BIN
      examples/game/res/frontend/assets/asteroid_002.png
  23. BIN
      examples/game/res/frontend/assets/background-sound.mp3
  24. BIN
      examples/game/res/frontend/assets/background_nebular.jpg
  25. BIN
      examples/game/res/frontend/assets/explosion_001.mp3
  26. BIN
      examples/game/res/frontend/assets/explosion_002.mp3
  27. BIN
      examples/game/res/frontend/assets/explosion_003.mp3
  28. BIN
      examples/game/res/frontend/assets/explosion_004.mp3
  29. BIN
      examples/game/res/frontend/assets/explosion_005.mp3
  30. BIN
      examples/game/res/frontend/assets/ship_explosion_001.mp3
  31. BIN
      examples/game/res/frontend/assets/ship_explosion_001.png
  32. BIN
      examples/game/res/frontend/assets/shot_001.mp3
  33. BIN
      examples/game/res/frontend/assets/shot_001.png
  34. BIN
      examples/game/res/frontend/assets/shot_002.mp3
  35. BIN
      examples/game/res/frontend/assets/shot_002.png
  36. BIN
      examples/game/res/frontend/assets/shot_003.mp3
  37. BIN
      examples/game/res/frontend/assets/shot_003.png
  38. BIN
      examples/game/res/frontend/assets/shot_004.mp3
  39. BIN
      examples/game/res/frontend/assets/shot_005.mp3
  40. BIN
      examples/game/res/frontend/assets/shot_006.mp3
  41. BIN
      examples/game/res/frontend/assets/shot_007.mp3
  42. BIN
      examples/game/res/frontend/assets/shot_008.mp3
  43. BIN
      examples/game/res/frontend/assets/shot_009.mp3
  44. BIN
      examples/game/res/frontend/assets/spaceShips_001.png
  45. BIN
      examples/game/res/frontend/assets/spaceShips_002.png
  46. BIN
      examples/game/res/frontend/assets/spaceShips_003.png
  47. BIN
      examples/game/res/frontend/assets/spaceShips_004.png
  48. BIN
      examples/game/res/frontend/assets/spaceShips_005.png
  49. BIN
      examples/game/res/frontend/assets/spaceShips_006.png
  50. BIN
      examples/game/res/frontend/assets/spaceShips_007.png
  51. BIN
      examples/game/res/frontend/assets/spaceShips_008.png
  52. BIN
      examples/game/res/frontend/assets/spaceShips_009.png
  53. BIN
      examples/game/res/frontend/assets/vanish_001.mp3
  54. 1 1
      examples/game/res/frontend/dist/frontend.js
  55. 34 7
      examples/game/res/frontend/index.html
  56. 96 9
      examples/game/res/frontend/src/backend/api-helper.ts
  57. 72 0
      examples/game/res/frontend/src/backend/asset-loader.ts
  58. 33 18
      examples/game/res/frontend/src/display/default.ts
  59. 173 7
      examples/game/res/frontend/src/display/engine.ts
  60. 19 2
      examples/game/res/frontend/src/display/types.ts
  61. 244 22
      examples/game/res/frontend/src/frontend.ts
  62. 135 67
      examples/game/res/frontend/src/game/game-controller.ts
  63. 135 0
      examples/game/res/frontend/src/game/lib.ts
  64. 107 13
      examples/game/res/frontend/src/game/objects.ts
  65. 20 2
      examples/game/res/frontend/src/helper.ts
  66. 1 1
      examples/game/res/frontend/webpack.config.js
  67. 27 0
      examples/game/res/frontend/world.html
  68. 17 5
      examples/game/res/scripts/const.ecal
  69. 148 36
      examples/game/res/scripts/engine.ecal
  70. 17 3
      examples/game/res/scripts/helper.ecal
  71. 179 42
      examples/game/res/scripts/main.ecal
  72. 172 10
      examples/game/res/scripts/templates.ecal
  73. 5 3
      examples/game/start.sh
  74. 2 0
      examples/game/watch_score.sh
  75. 0 2
      examples/game/watch_state.sh
  76. 1 1
      go.mod

+ 1 - 1
.gitignore

@@ -25,4 +25,4 @@
 /web
 /db
 /scripts
-eliasdb.config.json
+/eliasdb.config.json

+ 3 - 0
api/v1/cluster_test.go

@@ -30,6 +30,7 @@ import (
 )
 
 func TestClusterStorage(t *testing.T) {
+
 	clusterQueryURL := "http://localhost" + TESTPORT + EndpointClusterQuery
 	graphURL := "http://localhost" + TESTPORT + EndpointGraph
 
@@ -103,6 +104,7 @@ func TestClusterStorage(t *testing.T) {
 }
 
 func TestClusterQuery(t *testing.T) {
+
 	queryURL := "http://localhost" + TESTPORT + EndpointClusterQuery
 
 	st, _, res := sendTestRequest(queryURL, "GET", nil)
@@ -425,6 +427,7 @@ func TestClusterQuery(t *testing.T) {
 }
 
 func TestClusterQueryBigCluster(t *testing.T) {
+
 	queryURL := "http://localhost" + TESTPORT + EndpointClusterQuery
 
 	// Create a big cluster

+ 185 - 0
api/v1/ecal-sock.go

@@ -0,0 +1,185 @@
+/*
+ * EliasDB
+ *
+ * Copyright 2016 Matthias Ladkau. All rights reserved.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package v1
+
+import (
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"strings"
+
+	"devt.de/krotik/common/cryptutil"
+	"devt.de/krotik/common/errorutil"
+	"devt.de/krotik/common/stringutil"
+	"devt.de/krotik/ecal/engine"
+	"devt.de/krotik/ecal/scope"
+	"devt.de/krotik/eliasdb/api"
+	"devt.de/krotik/eliasdb/ecal"
+	"github.com/gorilla/websocket"
+)
+
+/*
+EndpointECALSock is the ECAL endpoint URL (rooted) for websocket operations. Handles everything under sock/...
+*/
+const EndpointECALSock = api.APIRoot + "/sock/"
+
+/*
+upgrader can upgrade normal requests to websocket communications
+*/
+var sockUpgrader = websocket.Upgrader{
+	Subprotocols:    []string{"ecal-sock"},
+	ReadBufferSize:  1024,
+	WriteBufferSize: 1024,
+}
+
+var sockCallbackError error
+
+/*
+ECALSockEndpointInst creates a new endpoint handler.
+*/
+func ECALSockEndpointInst() api.RestEndpointHandler {
+	return &ecalSockEndpoint{}
+}
+
+/*
+Handler object for ECAL websocket operations.
+*/
+type ecalSockEndpoint struct {
+	*api.DefaultEndpointHandler
+}
+
+/*
+HandleGET handles ECAL websocket operations.
+*/
+func (e *ecalSockEndpoint) HandleGET(w http.ResponseWriter, r *http.Request, resources []string) {
+
+	if api.SI != nil {
+		var body []byte
+
+		// Update the incomming connection to a websocket
+		// If the upgrade fails then the client gets an HTTP error response.
+
+		conn, err := sockUpgrader.Upgrade(w, r, nil)
+
+		if err != nil {
+
+			// We give details here on what went wrong
+
+			w.Write([]byte(err.Error()))
+			return
+		}
+
+		commID := fmt.Sprintf("%x", cryptutil.GenerateUUID())
+
+		wc := ecal.NewWebsocketConnection(commID, conn)
+
+		wc.Init()
+
+		if body, err = ioutil.ReadAll(r.Body); err == nil {
+
+			var data interface{}
+			json.Unmarshal(body, &data)
+
+			query := map[interface{}]interface{}{}
+			for k, v := range r.URL.Query() {
+				values := make([]interface{}, 0)
+				for _, val := range v {
+					values = append(values, val)
+				}
+				query[k] = values
+			}
+
+			header := map[interface{}]interface{}{}
+			for k, v := range r.Header {
+				header[k] = scope.ConvertJSONToECALObject(v)
+			}
+
+			proc := api.SI.Interpreter.RuntimeProvider.Processor
+			event := engine.NewEvent(fmt.Sprintf("WebSocketRequest"), []string{"db", "web", "sock"},
+				map[interface{}]interface{}{
+					"commID":     commID,
+					"path":       strings.Join(resources, "/"),
+					"pathList":   resources,
+					"bodyString": string(body),
+					"bodyJSON":   scope.ConvertJSONToECALObject(data),
+					"query":      query,
+					"method":     r.Method,
+					"header":     header,
+				})
+
+			// Add event that the websocket has been registered
+
+			if _, err = proc.AddEventAndWait(event, nil); err == nil {
+				api.SI.RegisterECALSock(wc)
+				defer func() {
+					api.SI.DeregisterECALSock(wc)
+				}()
+
+				for {
+					var fatal bool
+					var data map[string]interface{}
+
+					// Read websocket message
+
+					if data, fatal, err = wc.ReadData(); err != nil {
+
+						wc.WriteData(map[string]interface{}{
+							"error": err.Error(),
+						})
+
+						if fatal {
+							break
+						}
+
+						continue
+					}
+
+					if val, ok := data["close"]; ok && stringutil.IsTrueValue(fmt.Sprint(val)) {
+						wc.Close("")
+						break
+					}
+
+					event = engine.NewEvent(fmt.Sprintf("WebSocketRequest"), []string{"db", "web", "sock", "data"},
+						map[interface{}]interface{}{
+							"commID":   commID,
+							"path":     strings.Join(resources, "/"),
+							"pathList": resources,
+							"query":    query,
+							"method":   r.Method,
+							"header":   header,
+							"data":     scope.ConvertJSONToECALObject(data),
+						})
+
+					_, err = proc.AddEvent(event, nil)
+					errorutil.AssertOk(err)
+				}
+			}
+
+		}
+
+		if err != nil {
+			wc.Close(err.Error())
+			api.SI.Interpreter.RuntimeProvider.Logger.LogDebug(err)
+		}
+
+		return
+	}
+
+	http.Error(w, "Resource was not found", http.StatusNotFound)
+}
+
+/*
+SwaggerDefs is used to describe the endpoint in swagger.
+*/
+func (e *ecalSockEndpoint) SwaggerDefs(s map[string]interface{}) {
+	// No swagger definitions for this endpoint as it only handles websocket requests
+}

+ 180 - 0
api/v1/ecal-sock_test.go

@@ -0,0 +1,180 @@
+/*
+ * EliasDB
+ *
+ * Copyright 2016 Matthias Ladkau. All rights reserved.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package v1
+
+import (
+	"fmt"
+	"strings"
+	"sync"
+	"testing"
+	"time"
+
+	"devt.de/krotik/common/errorutil"
+	"devt.de/krotik/ecal/cli/tool"
+	"devt.de/krotik/ecal/engine"
+	"devt.de/krotik/ecal/util"
+	"devt.de/krotik/eliasdb/api"
+	"github.com/gorilla/websocket"
+)
+
+func TestECALSockConnectionErrors(t *testing.T) {
+	queryURL := "http://localhost" + TESTPORT + EndpointECALSock
+
+	_, _, res := sendTestRequest(queryURL+"foo?bar=123", "GET", nil)
+
+	if res != `Bad Request
+websocket: the client is not using the websocket protocol: 'upgrade' token not found in 'Connection' header` {
+		t.Error("Unexpected response:", res)
+		return
+	}
+
+	oldSI := api.SI
+	api.SI = nil
+	defer func() {
+		api.SI = oldSI
+	}()
+
+	_, _, res = sendTestRequest(queryURL+"foo?bar=123", "GET", nil)
+
+	if res != `Resource was not found` {
+		t.Error("Unexpected response:", res)
+		return
+	}
+}
+
+func TestECALSock(t *testing.T) {
+	queryURL := "ws://localhost" + TESTPORT + EndpointECALSock + "foo?bar=123"
+	lastUUID := ""
+	var lastDataEvent *engine.Event
+
+	resetSI()
+	api.SI.Interpreter = tool.NewCLIInterpreter()
+	testScriptDir := "testscripts"
+	api.SI.Interpreter.Dir = &testScriptDir
+	errorutil.AssertOk(api.SI.Interpreter.CreateRuntimeProvider("eliasdb-runtime"))
+	logger := util.NewMemoryLogger(10)
+	api.SI.Interpreter.RuntimeProvider.Logger = logger
+
+	errorutil.AssertOk(api.SI.Interpreter.RuntimeProvider.Processor.AddRule(&engine.Rule{
+		Name:            "WebSocketRegister",                 // Name
+		Desc:            "Handles a websocket communication", // Description
+		KindMatch:       []string{"db.web.sock"},             // Kind match
+		ScopeMatch:      []string{},
+		StateMatch:      nil,
+		Priority:        0,
+		SuppressionList: nil,
+		Action: func(p engine.Processor, m engine.Monitor, e *engine.Event, tid uint64) error {
+			lastUUID = fmt.Sprint(e.State()["commID"])
+			return nil
+		},
+	}))
+
+	wg := &sync.WaitGroup{}
+
+	errorutil.AssertOk(api.SI.Interpreter.RuntimeProvider.Processor.AddRule(&engine.Rule{
+		Name:            "WebSocketHandler",                  // Name
+		Desc:            "Handles a websocket communication", // Description
+		KindMatch:       []string{"db.web.sock.data"},        // Kind match
+		ScopeMatch:      []string{},
+		StateMatch:      nil,
+		Priority:        0,
+		SuppressionList: nil,
+		Action: func(p engine.Processor, m engine.Monitor, e *engine.Event, tid uint64) error {
+			lastDataEvent = e
+			wg.Done()
+			return nil
+		},
+	}))
+
+	api.SI.Interpreter.RuntimeProvider.Processor.Start()
+	defer api.SI.Interpreter.RuntimeProvider.Processor.Finish()
+
+	// Now do the actual testing
+
+	c, _, err := websocket.DefaultDialer.Dial(queryURL, nil)
+	if err != nil {
+		t.Error("Could not open websocket:", err)
+		return
+	}
+
+	_, message, err := c.ReadMessage()
+
+	if msg := formatJSONString(string(message)); err != nil || msg != `{
+  "type": "init_success",
+  "payload": {}
+}` {
+		t.Error("Unexpected response:", msg, err)
+		return
+	}
+
+	err = c.WriteMessage(websocket.TextMessage, []byte("buu"))
+	if err != nil {
+		t.Error("Could not send message:", err)
+		return
+	}
+
+	_, message, err = c.ReadMessage()
+	if msg := formatJSONString(string(message)); err != nil || msg != `{
+  "commID": "`+lastUUID+`",
+  "payload": {
+    "error": "invalid character 'b' looking for beginning of value"
+  },
+  "type": "data"
+}` {
+		t.Error("Unexpected response:", msg, err)
+		return
+	}
+
+	wg.Add(1)
+
+	err = c.WriteMessage(websocket.TextMessage, []byte(`{"foo":"bar"}`))
+	if err != nil {
+		t.Error("Could not send message:", err)
+		return
+	}
+
+	wg.Wait()
+
+	if data := lastDataEvent.State()["data"]; err != nil || fmt.Sprint(data) != `map[foo:bar]` {
+		t.Error("Unexpected response:", data, err)
+		return
+	}
+
+	err = c.WriteMessage(websocket.TextMessage, []byte(`{"close":true}`))
+	if err != nil {
+		t.Error("Could not send message:", err)
+		return
+	}
+
+	// Reset the connection and provoke an error
+
+	c, _, err = websocket.DefaultDialer.Dial(queryURL, nil)
+	if err != nil {
+		t.Error("Could not open websocket:", err)
+		return
+	}
+
+	c.Close()
+
+	for {
+
+		if logger.Size() > 0 {
+			break
+		}
+
+		time.Sleep(10 * time.Millisecond)
+	}
+
+	if !strings.Contains(logger.String(), "unexpected EOF") && !strings.Contains(logger.String(), "connection reset by peer") {
+		t.Error("Unexpected log output:", logger.String())
+		return
+	}
+}

+ 3 - 3
api/v1/ecal.go

@@ -31,14 +31,14 @@ EndpointECALInternal is the ECAL endpoint URL (rooted) for internal operations.
 const EndpointECALInternal = api.APIRoot + "/ecal/"
 
 /*
-EndpointECALPublic is the ECAL endpoint URL (rooted) for public API operations. Handles everything under api	/...
+EndpointECALPublic is the ECAL endpoint URL (rooted) for public API operations. Handles everything under api/...
 */
 const EndpointECALPublic = api.APIRoot + "/api/"
 
 /*
-EndpointECALInst creates a new endpoint handler.
+ECALEndpointInst creates a new endpoint handler.
 */
-func EndpointECALInst() api.RestEndpointHandler {
+func ECALEndpointInst() api.RestEndpointHandler {
 	return &ecalEndpoint{}
 }
 

+ 2 - 1
api/v1/ecal_test.go

@@ -124,7 +124,7 @@ Got internal web request: {
 Query data: [
   "1"
 ]
-error: ECAL error in eliasdb-runtime: aaa () (Line:26 Pos:3)
+error: ECAL error in eliasdb-runtime (testscripts/main.ecal): aaa () (Line:26 Pos:3)
 Got public web request: {
   "kind": "db.web.api",
   "name": "WebRequest",
@@ -160,6 +160,7 @@ Got public web request: {
 Body data: 123
 `); err != nil {
 		t.Error(err)
+		return
 	}
 
 	oldSI := api.SI

+ 0 - 2
api/v1/graphql-subscriptions_test.go

@@ -72,8 +72,6 @@ func TestGraphQLSubscriptionMissingPartition(t *testing.T) {
 func TestGraphQLSubscription(t *testing.T) {
 	queryURL := "ws://localhost" + TESTPORT + EndpointGraphQLSubscriptions + "main"
 
-	// Test missing partition
-
 	c, _, err := websocket.DefaultDialer.Dial(queryURL, nil)
 	if err != nil {
 		t.Error("Could not open websocket:", err)

+ 3 - 2
api/v1/rest.go

@@ -49,14 +49,15 @@ var V1EndpointMap = map[string]api.RestEndpointInst{
 	EndpointInfoQuery:            InfoEndpointInst,
 	EndpointQuery:                QueryEndpointInst,
 	EndpointQueryResult:          QueryResultEndpointInst,
-	EndpointECALInternal:         EndpointECALInst,
+	EndpointECALInternal:         ECALEndpointInst,
+	EndpointECALSock:             ECALSockEndpointInst,
 }
 
 /*
 V1PublicEndpointMap is a map of urls to public endpoints for version 1 of the API
 */
 var V1PublicEndpointMap = map[string]api.RestEndpointInst{
-	EndpointECALPublic: EndpointECALInst,
+	EndpointECALPublic: ECALEndpointInst,
 }
 
 // Helper functions

+ 2 - 0
config/config.go

@@ -62,6 +62,7 @@ const (
 	ClusterConfigFile        = "ClusterConfigFile"
 	ClusterLogHistory        = "ClusterLogHistory"
 	ECALScriptFolder         = "ECALScriptFolder"
+	ECALWorkerCount          = "ECALWorkerCount"
 	ECALEntryScript          = "ECALEntryScript"
 	ECALLogLevel             = "ECALLogLevel"
 	ECALLogFile              = "ECALLogFile"
@@ -99,6 +100,7 @@ var DefaultConfig = map[string]interface{}{
 	ClusterConfigFile:        "cluster.config.json",
 	ClusterLogHistory:        100.0,
 	ECALScriptFolder:         "scripts",
+	ECALWorkerCount:          10,
 	ECALEntryScript:          "main.ecal",
 	ECALLogLevel:             "info",
 	ECALLogFile:              "",

+ 15 - 3
ecal.md

@@ -8,8 +8,11 @@ ECAL was added for the following use-cases:
 - Enforce certain aspects of a database schema
 - Providing back-end logic for web applications using EliasDB
 
+The source of EliasDB comes with a game example which demonstrates some aspects of ECAL. See the documentation here: [TODO Link to documentation]
+
 ECAL related config values:
 --
+These ECAL related config options are available in `eliasdb.config.json`:
 | Configuration Option | Description |
 | --- | --- |
 | EnableECALScripts | Enable ECAL scripting. |
@@ -17,7 +20,7 @@ ECAL related config values:
 | ECALEntryScript | Entry script in the script folder. |
 | ECALLogFile | File in which the logs should be written (use an empty string for stdout). |
 | ECALLogLevel | Log level for the printed logs. |
-| EnableECALDebugServer | Start an ECAL debug server. |
+| EnableECALDebugServer | Enable debugging and start the ECAL debug server. Note: Activating debugging will slow down the interpreter speed significantly! |
 | ECALDebugServerHost | Host for the debug server. |
 | ECALDebugServerPort | Port for the debug server. |
 
@@ -33,8 +36,17 @@ The ECAL interpreter in EliasDB receives the following events:
 
 Web Request | ECAL event kind | Event state contents | Description
 -|-|-|-
-/db/api/|`db.web.api`| bodyJSON, bodyString, header, method, path, pathList, query | Any web request to /db/api/*. These endpoints are public and never require authentication.
-/db/ecal/|`db.web.ecal`| bodyJSON, bodyString, header, method, path, pathList, query | Any web request to /db/ecal/*. These endpoints are considered internal and require authentication if access control is enabled.
+/db/api/|`db.web.api`| bodyJSON, bodyString, header, method, path, pathList, query | Any web request to /db/api/... These endpoints are public and never require authentication.
+/db/ecal/|`db.web.ecal`| bodyJSON, bodyString, header, method, path, pathList, query | Any web request to /db/ecal/... These endpoints are considered internal and require authentication if access control is enabled.
+/db/sock/|`db.web.sock`| bodyJSON, bodyString, commID, header, method, path, pathList, query | Any web request to /db/sock/... These endpoints are used to initiate websocket connections.
+-|`db.web.sock.data`| commID, data, header, method, path, pathList, query | An existing websocket connection received some JSON object data. If the close attribute of the object is set to true then the websocket connection is closed.
+
+EliasDB can receive the following events from the ECAL interpreter:
+
+ECAL event kind | Event state contents | Description
+-|-|-
+db.web.sock.msg | commID, payload, close | The payload is send to a client with an open websocket identified by the commID.
+
 
 EliasDB Graph Event | ECAL event kind | Event state contents | Description
 -|-|-|-

+ 84 - 8
ecal/interpreter.go

@@ -16,8 +16,13 @@ import (
 	"path/filepath"
 	"strings"
 
+	"devt.de/krotik/common/datautil"
 	"devt.de/krotik/common/fileutil"
+	"devt.de/krotik/common/stringutil"
 	"devt.de/krotik/ecal/cli/tool"
+	ecalconfig "devt.de/krotik/ecal/config"
+	"devt.de/krotik/ecal/engine"
+	"devt.de/krotik/ecal/scope"
 	"devt.de/krotik/ecal/stdlib"
 	"devt.de/krotik/ecal/util"
 	"devt.de/krotik/eliasdb/config"
@@ -40,6 +45,8 @@ type ScriptingInterpreter struct {
 	RunDebugServer  bool   // Run a debug server
 	DebugServerHost string // Debug server host
 	DebugServerPort string // Debug server port
+
+	WebsocketConnections *datautil.MapCache
 }
 
 /*
@@ -47,14 +54,15 @@ NewScriptingInterpreter returns a new ECAL scripting interpreter.
 */
 func NewScriptingInterpreter(scriptFolder string, gm *graph.Manager) *ScriptingInterpreter {
 	return &ScriptingInterpreter{
-		GM:              gm,
-		Dir:             scriptFolder,
-		EntryFile:       filepath.Join(scriptFolder, config.Str(config.ECALEntryScript)),
-		LogLevel:        config.Str(config.ECALLogLevel),
-		LogFile:         config.Str(config.ECALLogFile),
-		RunDebugServer:  config.Bool(config.EnableECALDebugServer),
-		DebugServerHost: config.Str(config.ECALDebugServerHost),
-		DebugServerPort: config.Str(config.ECALDebugServerPort),
+		GM:                   gm,
+		Dir:                  scriptFolder,
+		EntryFile:            filepath.Join(scriptFolder, config.Str(config.ECALEntryScript)),
+		LogLevel:             config.Str(config.ECALLogLevel),
+		LogFile:              config.Str(config.ECALLogFile),
+		RunDebugServer:       config.Bool(config.EnableECALDebugServer),
+		DebugServerHost:      config.Str(config.ECALDebugServerHost),
+		DebugServerPort:      config.Str(config.ECALDebugServerPort),
+		WebsocketConnections: datautil.NewMapCache(5000, 0),
 	}
 }
 
@@ -88,6 +96,10 @@ func (si *ScriptingInterpreter) Run() error {
 		i := tool.NewCLIInterpreter()
 		si.Interpreter = i
 
+		// Set worker count in ecal config
+
+		ecalconfig.Config[ecalconfig.WorkerCount] = config.Config[config.ECALWorkerCount]
+
 		i.Dir = &si.Dir
 		i.LogFile = &si.LogFile
 		i.LogLevel = &si.LogLevel
@@ -101,6 +113,21 @@ func (si *ScriptingInterpreter) Run() error {
 
 		AddEliasDBStdlibFunctions(si.GM)
 
+		// Adding rules
+
+		sockRule := &engine.Rule{
+			Name:            "EliasDB-websocket-communication-rule", // Name
+			Desc:            "Handles a websocket communication",    // Description
+			KindMatch:       []string{"db.web.sock.msg"},            // Kind match
+			ScopeMatch:      []string{},
+			StateMatch:      nil,
+			Priority:        0,
+			SuppressionList: nil,
+			Action:          si.HandleECALSockEvent,
+		}
+
+		si.Interpreter.CustomRules = append(si.Interpreter.CustomRules, sockRule)
+
 		if err == nil {
 
 			if si.RunDebugServer {
@@ -140,6 +167,55 @@ func (si *ScriptingInterpreter) Run() error {
 	return err
 }
 
+/*
+RegisterECALSock registers a websocket which should be connected to ECAL events.
+*/
+func (si *ScriptingInterpreter) RegisterECALSock(conn *WebsocketConnection) {
+	si.WebsocketConnections.Put(conn.CommID, conn)
+}
+
+/*
+DeregisterECALSock removes a registered websocket.
+*/
+func (si *ScriptingInterpreter) DeregisterECALSock(conn *WebsocketConnection) {
+	si.WebsocketConnections.Remove(conn.CommID)
+}
+
+/*
+HandleECALSockEvent handles websocket events from the ECAL interpreter (db.web.sock.msg events).
+*/
+func (si *ScriptingInterpreter) HandleECALSockEvent(p engine.Processor, m engine.Monitor, e *engine.Event, tid uint64) error {
+	state := e.State()
+	payload := scope.ConvertECALToJSONObject(state["payload"])
+	shouldClose := stringutil.IsTrueValue(fmt.Sprint(state["close"]))
+
+	id := "null"
+	if commID, ok := state["commID"]; ok {
+		id = fmt.Sprint(commID)
+	}
+
+	err := fmt.Errorf("Could not send data to unknown websocket - commID: %v", id)
+
+	if conn, ok := si.WebsocketConnections.Get(id); ok {
+		err = nil
+		wconn := conn.(*WebsocketConnection)
+		wconn.WriteData(map[string]interface{}{
+			"commID":  id,
+			"payload": payload,
+			"close":   shouldClose,
+		})
+
+		if shouldClose {
+			wconn.Close("")
+		}
+	}
+
+	return err
+}
+
+/*
+AddEliasDBStdlibFunctions adds EliasDB related ECAL stdlib functions.
+*/
 func AddEliasDBStdlibFunctions(gm *graph.Manager) {
 	stdlib.AddStdlibPkg("db", "EliasDB related functions")
 

+ 5 - 5
ecal/interpreter_test.go

@@ -151,7 +151,7 @@ log("test insert")
 raise("some error")
 `)
 
-	if err := ds.Run(); err == nil || err.Error() != `ECAL error in eliasdb-runtime: some error () (Line:2 Pos:1)
+	if err := ds.Run(); err == nil || err.Error() != `ECAL error in eliasdb-runtime (testscripts/main.ecal): some error () (Line:2 Pos:1)
   raise("some error") (testscripts/main.ecal:2)` {
 		t.Error("Unexpected result:", err)
 		return
@@ -310,7 +310,7 @@ Got event: {
 	}))
 
 	if err == nil || err.Error() != `GraphError: Graph rule error (Taskerror:
-EliasDB: db.node.store -> mysink : ECAL error in eliasdb-runtime: Oh no () (Line:8 Pos:7))` {
+EliasDB: db.node.store -> mysink : ECAL error in eliasdb-runtime (testscripts/main.ecal): Oh no () (Line:8 Pos:7))` {
 		t.Error("Unexpected result:", err)
 		return
 	}
@@ -345,7 +345,7 @@ EliasDB: db.node.store -> mysink : ECAL error in eliasdb-runtime: Oh no () (Line
 	})))
 
 	if err == nil || err.Error() != `GraphError: Graph rule error (Taskerror:
-EliasDB: db.edge.store -> mysink : ECAL error in eliasdb-runtime: Oh no edge () (Line:15 Pos:7))` {
+EliasDB: db.edge.store -> mysink : ECAL error in eliasdb-runtime (testscripts/main.ecal): Oh no edge () (Line:15 Pos:7))` {
 		t.Error("Unexpected result:", err)
 		return
 	}
@@ -364,7 +364,7 @@ EliasDB: db.edge.store -> mysink : ECAL error in eliasdb-runtime: Oh no edge ()
 	})))
 
 	if err == nil || err.Error() != `GraphError: Graph rule error (Taskerror:
-EliasDB: db.edge.created -> mysink : ECAL error in eliasdb-runtime: Oh no edge2 () (Line:18 Pos:7))` {
+EliasDB: db.edge.created -> mysink : ECAL error in eliasdb-runtime (testscripts/main.ecal): Oh no edge2 () (Line:18 Pos:7))` {
 		t.Error("Unexpected result:", err)
 		return
 	}
@@ -383,7 +383,7 @@ EliasDB: db.edge.created -> mysink : ECAL error in eliasdb-runtime: Oh no edge2
 	})))
 
 	if err == nil || err.Error() != `GraphError: Graph rule error (Taskerror:
-EliasDB: db.edge.updated -> mysink : ECAL error in eliasdb-runtime: Oh no edge3 () (Line:21 Pos:7))` {
+EliasDB: db.edge.updated -> mysink : ECAL error in eliasdb-runtime (testscripts/main.ecal): Oh no edge3 () (Line:21 Pos:7))` {
 		t.Error("Unexpected result:", err)
 		return
 	}

+ 98 - 0
ecal/websocket.go

@@ -0,0 +1,98 @@
+/*
+ * EliasDB
+ *
+ * Copyright 2016 Matthias Ladkau. All rights reserved.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package ecal
+
+import (
+	"encoding/json"
+	"sync"
+	"time"
+
+	"github.com/gorilla/websocket"
+)
+
+/*
+WebsocketConnection models a single websocket connection.
+
+Websocket connections support one concurrent reader and one concurrent writer.
+See: https://godoc.org/github.com/gorilla/websocket#hdr-Concurrency
+*/
+type WebsocketConnection struct {
+	CommID string
+	Conn   *websocket.Conn
+	RMutex *sync.Mutex
+	WMutex *sync.Mutex
+}
+
+/*
+NewWebsocketConnection creates a new WebsocketConnection object.
+*/
+func NewWebsocketConnection(commID string, c *websocket.Conn) *WebsocketConnection {
+	return &WebsocketConnection{
+		CommID: commID,
+		Conn:   c,
+		RMutex: &sync.Mutex{},
+		WMutex: &sync.Mutex{}}
+}
+
+/*
+Init initializes the websocket connection.
+*/
+func (wc *WebsocketConnection) Init() {
+	wc.WMutex.Lock()
+	defer wc.WMutex.Unlock()
+	wc.Conn.WriteMessage(websocket.TextMessage, []byte(`{"type":"init_success","payload":{}}`))
+}
+
+/*
+ReadData reads data from the websocket connection.
+*/
+func (wc *WebsocketConnection) ReadData() (map[string]interface{}, bool, error) {
+	var data map[string]interface{}
+	var fatal = true
+
+	wc.RMutex.Lock()
+	_, msg, err := wc.Conn.ReadMessage()
+	wc.RMutex.Unlock()
+
+	if err == nil {
+		fatal = false
+		err = json.Unmarshal(msg, &data)
+	}
+
+	return data, fatal, err
+}
+
+/*
+WriteData writes data to the websocket.
+*/
+func (wc *WebsocketConnection) WriteData(data map[string]interface{}) {
+	wc.WMutex.Lock()
+	defer wc.WMutex.Unlock()
+
+	jsonData, _ := json.Marshal(map[string]interface{}{
+		"commID":  wc.CommID,
+		"type":    "data",
+		"payload": data,
+	})
+
+	wc.Conn.WriteMessage(websocket.TextMessage, jsonData)
+}
+
+/*
+Close closes the websocket connection.
+*/
+func (wc *WebsocketConnection) Close(msg string) {
+	wc.Conn.WriteControl(websocket.CloseMessage,
+		websocket.FormatCloseMessage(
+			websocket.CloseNormalClosure, msg), time.Now().Add(10*time.Second))
+
+	wc.Conn.Close()
+}

+ 133 - 0
ecal/websocket_test.go

@@ -0,0 +1,133 @@
+/*
+ * EliasDB
+ *
+ * Copyright 2016 Matthias Ladkau. All rights reserved.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+package ecal
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"sync"
+	"testing"
+
+	"devt.de/krotik/common/errorutil"
+	"devt.de/krotik/common/httputil"
+	"devt.de/krotik/ecal/engine"
+	"github.com/gorilla/websocket"
+)
+
+const TESTPORT = ":9090"
+
+func TestWebsocketHandling(t *testing.T) {
+	sockUpgrader := websocket.Upgrader{
+		Subprotocols:    []string{"ecal-sock"},
+		ReadBufferSize:  1024,
+		WriteBufferSize: 1024,
+	}
+
+	si := NewScriptingInterpreter("", nil)
+
+	http.HandleFunc("/httpserver_test", func(w http.ResponseWriter, r *http.Request) {
+
+		conn, err := sockUpgrader.Upgrade(w, r, nil)
+		errorutil.AssertOk(err)
+
+		wsconn := NewWebsocketConnection("123", conn)
+		si.RegisterECALSock(wsconn)
+		defer func() {
+			si.DeregisterECALSock(wsconn)
+		}()
+
+		wc := NewWebsocketConnection("123", conn)
+
+		wc.Init()
+
+		data, _, err := wc.ReadData()
+		errorutil.AssertOk(err)
+		errorutil.AssertTrue(fmt.Sprint(data) == "map[foo:bar]", fmt.Sprint("data is:", data))
+
+		// Simulate that an event is injectd and writes to the websocket
+
+		event := engine.NewEvent(fmt.Sprintf("WebSocketRequest"), []string{"db", "web", "sock", "msg"},
+			map[interface{}]interface{}{
+				"commID":  "123",
+				"payload": "bla",
+				"close":   true,
+			})
+
+		si.HandleECALSockEvent(nil, nil, event, 0)
+	})
+
+	hs := &httputil.HTTPServer{}
+
+	var wg sync.WaitGroup
+	wg.Add(1)
+
+	go hs.RunHTTPServer(TESTPORT, &wg)
+
+	wg.Wait()
+
+	// Server is started
+
+	if hs.LastError != nil {
+		t.Error(hs.LastError)
+		return
+
+	}
+
+	queryURL := "ws://localhost" + TESTPORT + "/httpserver_test"
+
+	c, _, err := websocket.DefaultDialer.Dial(queryURL, nil)
+	if err != nil {
+		t.Error("Could not open websocket:", err)
+		return
+	}
+
+	_, message, err := c.ReadMessage()
+
+	if msg := formatJSONString(string(message)); err != nil || msg != `{
+  "type": "init_success",
+  "payload": {}
+}` {
+		t.Error("Unexpected response:", msg, err)
+		return
+	}
+
+	err = c.WriteMessage(websocket.TextMessage, []byte(`{"foo":"bar"}`))
+	if err != nil {
+		t.Error("Could not send message:", err)
+		return
+	}
+
+	_, message, err = c.ReadMessage()
+
+	if msg := formatJSONString(string(message)); err != nil || msg != `{
+  "commID": "123",
+  "payload": {
+    "close": true,
+    "commID": "123",
+    "payload": "bla"
+  },
+  "type": "data"
+}` {
+		t.Error("Unexpected response:", msg, err)
+		return
+	}
+}
+
+/*
+formatJSONString formats a given JSON string.
+*/
+func formatJSONString(str string) string {
+	out := bytes.Buffer{}
+	errorutil.AssertOk(json.Indent(&out, []byte(str), "", "  "))
+	return out.String()
+}

+ 14 - 0
examples/game/doc/frontend.md

@@ -0,0 +1,14 @@
+frontend.ts
+    Provides the entry start() function. Here we mainly create objects and
+    put them together. After registering the game world, creating a websocket
+    for backend communication and registering the actual player the game is
+    started once the first update is received through the websocket.
+
+game/
+    game-controller.ts - MainGameController - Contains only a communication handler to handle
+    messages from the actual game controller in the backend.
+    
+display/
+    engine.ts - MainDisplayController - Object containing the draw loop which visualises
+    the current state of the game model.
+

+ 34 - 0
examples/game/eliasdb.config.json

@@ -0,0 +1,34 @@
+{
+    "ClusterConfigFile": "cluster.config.json",
+    "ClusterLogHistory": 100,
+    "ClusterStateInfoFile": "cluster.stateinfo",
+    "CookieMaxAgeSeconds": "86400",
+    "ECALDebugServerHost": "127.0.0.1",
+    "ECALDebugServerPort": "33274",
+    "ECALEntryScript": "main.ecal",
+    "ECALLogFile": "",
+    "ECALLogLevel": "info",
+    "ECALScriptFolder": "scripts",
+    "ECALWorkerCount": 10,
+    "EnableAccessControl": false,
+    "EnableCluster": false,
+    "EnableClusterTerminal": false,
+    "EnableECALDebugServer": false,
+    "EnableECALScripts": false,
+    "EnableReadOnly": false,
+    "EnableWebFolder": true,
+    "EnableWebTerminal": true,
+    "HTTPSCertificate": "cert.pem",
+    "HTTPSHost": "127.0.0.1",
+    "HTTPSKey": "key.pem",
+    "HTTPSPort": "9090",
+    "LocationAccessDB": "access.db",
+    "LocationDatastore": "db",
+    "LocationHTTPS": "ssl",
+    "LocationUserDB": "users.db",
+    "LocationWebFolder": "web",
+    "LockFile": "eliasdb.lck",
+    "MemoryOnlyStorage": false,
+    "ResultCacheMaxAgeSeconds": 0,
+    "ResultCacheMaxSize": 0
+}

+ 5 - 0
examples/game/get_score.sh

@@ -0,0 +1,5 @@
+#!/bin/sh
+# Query the score nodes in the main game world
+../../eliasdb console -exec "get score"
+# Query the conf node in the main game world
+../../eliasdb console -exec "get conf"

+ 0 - 7
examples/game/get_state.sh

@@ -1,7 +0,0 @@
-#!/bin/sh
-# Query the stat node in the main game world
-../../eliasdb console -exec "get stats"
-# Query the stat node in the main game world
-../../eliasdb console -exec "get conf"
-# List all objects in the main world
-../../eliasdb console -exec "get obj"

+ 33 - 0
examples/game/res/eliasdb.config.json

@@ -0,0 +1,33 @@
+{
+    "ClusterConfigFile": "cluster.config.json",
+    "ClusterLogHistory": 100,
+    "ClusterStateInfoFile": "cluster.stateinfo",
+    "CookieMaxAgeSeconds": "86400",
+    "ECALDebugServerHost": "127.0.0.1",
+    "ECALDebugServerPort": "33274",
+    "ECALEntryScript": "main.ecal",
+    "ECALLogFile": "",
+    "ECALLogLevel": "info",
+    "ECALScriptFolder": "scripts",
+    "EnableAccessControl": false,
+    "EnableCluster": false,
+    "EnableClusterTerminal": false,
+    "EnableECALDebugServer": false,
+    "EnableECALScripts": true,
+    "EnableReadOnly": false,
+    "EnableWebFolder": true,
+    "EnableWebTerminal": true,
+    "HTTPSCertificate": "cert.pem",
+    "HTTPSHost": "127.0.0.1",
+    "HTTPSKey": "key.pem",
+    "HTTPSPort": "9090",
+    "LocationAccessDB": "access.db",
+    "LocationDatastore": "db",
+    "LocationHTTPS": "ssl",
+    "LocationUserDB": "users.db",
+    "LocationWebFolder": "web",
+    "LockFile": "eliasdb.lck",
+    "MemoryOnlyStorage": true,
+    "ResultCacheMaxAgeSeconds": 0,
+    "ResultCacheMaxSize": 0
+}

+ 11 - 0
examples/game/res/frontend/assets/asset_license.txt

@@ -0,0 +1,11 @@
+All assets are public domain (CC0)
+
+See: https://opengameart.org/art-search-advanced?keys=space&title=&field_art_tags_tid_op=or&field_art_tags_tid=&name=&field_art_type_tid%5B%5D=9&field_art_type_tid%5B%5D=10&field_art_type_tid%5B%5D=7273&field_art_type_tid%5B%5D=14&field_art_type_tid%5B%5D=12&field_art_type_tid%5B%5D=13&field_art_type_tid%5B%5D=11&field_art_licenses_tid%5B%5D=4&sort_by=score&sort_order=DESC&items_per_page=24&Collection=
+
+Credits:
+
+Background image: Cuzco
+Asteroid images: BlackMoon Design
+Shot images: Bonsaiheldin
+Ship images: Kenny (Kenney.nl)
+Background sound: aquinn

BIN
examples/game/res/frontend/assets/asteroid_001.png


BIN
examples/game/res/frontend/assets/asteroid_002.png


BIN
examples/game/res/frontend/assets/background-sound.mp3


BIN
examples/game/res/frontend/assets/background_nebular.jpg


BIN
examples/game/res/frontend/assets/explosion_001.mp3


BIN
examples/game/res/frontend/assets/explosion_002.mp3


BIN
examples/game/res/frontend/assets/explosion_003.mp3


BIN
examples/game/res/frontend/assets/explosion_004.mp3


BIN
examples/game/res/frontend/assets/explosion_005.mp3


BIN
examples/game/res/frontend/assets/ship_explosion_001.mp3


BIN
examples/game/res/frontend/assets/ship_explosion_001.png


BIN
examples/game/res/frontend/assets/shot_001.mp3


BIN
examples/game/res/frontend/assets/shot_001.png


BIN
examples/game/res/frontend/assets/shot_002.mp3


BIN
examples/game/res/frontend/assets/shot_002.png


BIN
examples/game/res/frontend/assets/shot_003.mp3


BIN
examples/game/res/frontend/assets/shot_003.png


BIN
examples/game/res/frontend/assets/shot_004.mp3


BIN
examples/game/res/frontend/assets/shot_005.mp3


BIN
examples/game/res/frontend/assets/shot_006.mp3


BIN
examples/game/res/frontend/assets/shot_007.mp3


BIN
examples/game/res/frontend/assets/shot_008.mp3


BIN
examples/game/res/frontend/assets/shot_009.mp3


BIN
examples/game/res/frontend/assets/spaceShips_001.png


BIN
examples/game/res/frontend/assets/spaceShips_002.png


BIN
examples/game/res/frontend/assets/spaceShips_003.png


BIN
examples/game/res/frontend/assets/spaceShips_004.png


BIN
examples/game/res/frontend/assets/spaceShips_005.png


BIN
examples/game/res/frontend/assets/spaceShips_006.png


BIN
examples/game/res/frontend/assets/spaceShips_007.png


BIN
examples/game/res/frontend/assets/spaceShips_008.png


BIN
examples/game/res/frontend/assets/spaceShips_009.png


BIN
examples/game/res/frontend/assets/vanish_001.mp3


File diff suppressed because it is too large
+ 1 - 1
examples/game/res/frontend/dist/frontend.js


+ 34 - 7
examples/game/res/frontend/index.html

@@ -3,18 +3,45 @@
         <meta charset="UTF-8" />
         <script src="dist/frontend.js"></script>
         <style>
-            .mainscreen {
-                border: solid 1px;
+            .landing-page-element {
+                height: 30%;
+                max-height: 150px;
+                text-align: center;
+                margin: auto;
+                width: 50%;
+                max-width: 600px;
+                padding: 10px;
             }
         </style>
     </head>
     <body>
-        <canvas class="mainscreen" id="screen"></canvas>
-        <div id="game-debug-out"></div>
+        <h1 class="landing-page-element">Astro</h1>
+        <form action="world.html" method="GET">
+            <table class="landing-page-element">
+                <tr>
+                    <td>Game:</td>
+                    <td><input type="text" name="game" value="main" /></td>
+                </tr>
+                <tr>
+                    <td>Player:</td>
+                    <td>
+                        <input
+                            id="player-name-input"
+                            type="text"
+                            name="player"
+                            value=""
+                        />
+                    </td>
+                </tr>
+                <tr>
+                    <td colspan="2"><input type="submit" value="Start" /></td>
+                </tr>
+            </table>
+        </form>
         <script>
-            mainDisplay.start('screen').catch(function (err) {
-                console.error(err.toString());
-            });
+            document.getElementById(
+                'player-name-input'
+            ).value = astro.generatePlayerName();
         </script>
     </body>
 </html>

+ 96 - 9
examples/game/res/frontend/src/backend/api-helper.ts

@@ -1,26 +1,110 @@
+/**
+ * EliasDB - JavaScript ECAL client library
+ *
+ * Copyright 2021 Matthias Ladkau. All rights reserved.
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ *
+ */
 export enum RequestMetod {
     Post = 'post',
     Get = 'get'
 }
 
 export class BackendClient {
+    /**
+     * Host this client is connected to.
+     */
     protected host: string;
 
-    protected partition: string;
-
+    /**
+     * API endpoint for this client.
+     */
     protected apiEndpoint: string;
 
-    public constructor(
-        host: string = window.location.host,
-        partition: string = 'main'
-    ) {
+    /**
+     * Websocket endpoint for this client.
+     */
+    protected sockEndpoint: string;
+
+    public constructor(host: string = window.location.host) {
         this.host = host;
-        this.partition = partition;
         this.apiEndpoint = `https://${host}/db/ecal`;
+        this.sockEndpoint = `wss://${host}/db/sock`;
+    }
+
+    /**
+     * Process a new websocket message. This function expects
+     * to have a bound update function on its context object.
+     *
+     * @param msg New message.
+     */
+    protected message(msg: MessageEvent) {
+        const pmsg = JSON.parse(msg.data);
+
+        if (pmsg.type == 'init_success') {
+            console.log('New ECAL websocket established');
+        } else if (pmsg.type == 'data') {
+            (this as any).update(pmsg.payload);
+        }
     }
 
     /**
-     * Run a GraphQL query or mutation and return the response.
+     * Create and open a new websocket to the ECAL backend.
+     *
+     * @param path Websocket path.
+     * @param data URL query data for the initial request.
+     * @param update Update callback.
+     *
+     * The data parameter of the update callback has the following form:
+     * {
+     *     close: Boolean if the server closes the connection
+     *     commID: Unique communication ID which the server uses for tracking
+     *     payload: Payload data (defined by ECAL backend script)
+     * }
+     */
+    public async createSock(
+        path: string,
+        data: any,
+        update: (data: any) => void
+    ): Promise<WebSocket> {
+        const params = Object.keys(data)
+            .map((key) => {
+                const val = data[key];
+                return `${key}=${encodeURIComponent(val)}`;
+            })
+            .join('&');
+
+        const url = `${this.sockEndpoint}${path}?${params}`;
+        const boundMessageFunc = this.message.bind({
+            update
+        });
+
+        return new Promise(function (resolve, reject) {
+            try {
+                const ws = new WebSocket(url);
+
+                ws.onmessage = boundMessageFunc;
+                ws.onopen = () => {
+                    resolve(ws);
+                };
+            } catch (err) {
+                reject(err);
+            }
+        });
+    }
+
+    /**
+     * Send data over an existing websocket
+     */
+    public sendSockData(ws: WebSocket, data: any) {
+        ws.send(JSON.stringify(data));
+    }
+
+    /**
+     * Send a request to the ECAL backend.
      *
      * @param query Query to run.
      * @param variables List of variable values. The query must define these
@@ -59,8 +143,11 @@ export class BackendClient {
                         resolve(JSON.parse(http.response));
                     } else {
                         let err: string;
+
                         try {
-                            err = JSON.parse(http.responseText)['errors'];
+                            err =
+                                JSON.parse(http.responseText)['error'] ||
+                                http.responseText.trim();
                         } catch {
                             err = http.responseText.trim();
                         }

+ 72 - 0
examples/game/res/frontend/src/backend/asset-loader.ts

@@ -0,0 +1,72 @@
+export enum AssetType {
+    ImageAsset = 'image',
+    SoundAsset = 'sound'
+}
+
+export interface AssetDefinition {
+    id: string;
+    type: AssetType;
+    file: string;
+    image?: HTMLImageElement;
+    audio?: HTMLAudioElement[];
+}
+
+export class AssetLoader {
+    /**
+     * Host this client is connected to.
+     */
+    protected host: string;
+
+    /**
+     * URL prefix for asset locations
+     */
+    protected assetURLPrefix: string;
+
+    protected loadedAssets: Record<string, AssetDefinition> = {};
+
+    public constructor(
+        host: string = window.location.host,
+        path: string = 'assets'
+    ) {
+        this.host = host;
+        this.assetURLPrefix = `https://${host}/${path}/`;
+    }
+
+    public async preload(
+        assets: AssetDefinition[],
+        callback: (assets: Record<string, AssetDefinition>) => void
+    ) {
+        let loadCounter = 0;
+        let checkFinishedLoading = () => {
+            loadCounter++;
+            console.log(`Asset ${loadCounter} of ${assets.length} loaded.`);
+            if (loadCounter === assets.length) {
+                callback(this.loadedAssets);
+            }
+        };
+
+        for (let ass of assets) {
+            if (ass.type === AssetType.ImageAsset) {
+                var im = new Image();
+                im.src = `${this.assetURLPrefix}${ass.file}`;
+                im.onload = checkFinishedLoading;
+                ass.image = im;
+                this.loadedAssets[ass.id] = ass;
+            } else if (ass.type === AssetType.SoundAsset) {
+                var ad = new Audio();
+                ad.src = `${this.assetURLPrefix}${ass.file}`;
+                ad.oncanplaythrough = checkFinishedLoading;
+                ass.audio = [ad];
+
+                // Create multiple audio objects so audio effects can be overlapping
+
+                for (let i = 0; i < 6; i++) {
+                    var ad = new Audio();
+                    ad.src = `${this.assetURLPrefix}${ass.file}`;
+                    ass.audio.push(ad);
+                }
+                this.loadedAssets[ass.id] = ass;
+            }
+        }
+    }
+}

+ 33 - 18
examples/game/res/frontend/src/display/default.ts

@@ -1,4 +1,6 @@
 import { EngineEventHandler, PlayerState } from './types';
+import { AssetDefinition } from '../backend/asset-loader';
+import { AnimationController } from '../game/lib';
 
 // This file contains default values for game display object
 
@@ -11,26 +13,32 @@ export abstract class DefaultEngineEventHandler implements EngineEventHandler {
     public onkeydown(state: PlayerState, e: KeyboardEvent) {
         e = e || window.event;
 
+        let action = 'move';
+
         switch (e.code) {
+            case 'ControlLeft':
+            case 'Space':
+                state.stateUpdate('fire', []);
+                break; // Fire
             case 'ArrowUp':
                 state.speed += 0.006;
-                state.stateUpdate(['speed']);
+                state.stateUpdate(action, ['speed']);
                 break; // Move forward
             case 'ArrowDown':
                 state.speed += -0.008;
-                state.stateUpdate(['speed']);
+                state.stateUpdate(action, ['speed']);
                 break; // Move backward
             case 'ArrowRight':
                 if (e.ctrlKey || e.shiftKey) {
                     state.strafe = 1; // Strafe right
-                    state.stateUpdate(['strafe']);
+                    state.stateUpdate(action, ['strafe']);
                 } else {
                     state.dir = 1; // Rotate right
                     if (state.rotSpeed < state.maxRotSpeed) {
                         state.rotSpeed = state.deltaRotSpeed(state.rotSpeed);
-                        state.stateUpdate(['dir', 'rotSpeed']);
+                        state.stateUpdate(action, ['dir', 'rotSpeed']);
                     } else {
-                        state.stateUpdate(['dir']);
+                        state.stateUpdate(action, ['dir']);
                     }
                 }
                 break;
@@ -38,14 +46,14 @@ export abstract class DefaultEngineEventHandler implements EngineEventHandler {
             case 'ArrowLeft':
                 if (e.ctrlKey || e.shiftKey) {
                     state.strafe = -1; // Strafe left
-                    state.stateUpdate(['strafe']);
+                    state.stateUpdate(action, ['strafe']);
                 } else {
                     state.dir = -1; // Rotate left
                     if (state.rotSpeed < state.maxRotSpeed) {
                         state.rotSpeed = state.deltaRotSpeed(state.rotSpeed);
-                        state.stateUpdate(['dir', 'rotSpeed']);
+                        state.stateUpdate(action, ['dir', 'rotSpeed']);
                     } else {
-                        state.stateUpdate(['dir']);
+                        state.stateUpdate(action, ['dir']);
                     }
                 }
                 break;
@@ -59,14 +67,14 @@ export abstract class DefaultEngineEventHandler implements EngineEventHandler {
     public onkeyup(state: PlayerState, e: KeyboardEvent) {
         e = e || window.event;
 
-        if (e.code == 'ArrowRight' || e.code == 'ArrowLeft') {
-            // Stop rotating and strafing
+        //if (e.code == 'ArrowRight' || e.code == 'ArrowLeft') {
+        // Stop rotating and strafing
 
-            state.dir = 0;
-            state.strafe = 0;
-            state.rotSpeed = state.minRotSpeed;
-            state.stateUpdate(['dir', 'strafe', 'rotSpeed']);
-        }
+        state.dir = 0;
+        state.strafe = 0;
+        state.rotSpeed = state.minRotSpeed;
+        state.stateUpdate('stop move', ['dir', 'strafe', 'rotSpeed']);
+        //}
 
         this.stopBubbleEvent(e);
     }
@@ -90,6 +98,7 @@ export abstract class DefaultEngineEventHandler implements EngineEventHandler {
  */
 export abstract class DefaultEngineOptions {
     public backdrop: CanvasImageSource | null = null;
+    public assets: Record<string, AssetDefinition> = {};
     public screenWidth: number = 640;
     public screenHeight: number = 480;
     public screenElementWidth: number = 640;
@@ -105,6 +114,10 @@ export abstract class DefaultSpriteState {
     public x: number = 20;
     public y: number = 20;
 
+    public kind: string = '';
+
+    public owner: string = '';
+
     public dim: number = 20;
 
     public isMoving: boolean = true;
@@ -119,10 +132,14 @@ export abstract class DefaultSpriteState {
     public strafe: number = 0;
     public moveSpeed: number = 0;
 
+    public animation: AnimationController | null = null;
+
     public setState(state: Record<string, any>): void {
         this.id = state.id || this.id;
         this.x = state.x || this.x;
         this.y = state.y || this.y;
+        this.kind = state.kind || this.kind;
+        this.owner = state.owner || this.owner;
         this.dim = state.dim || this.dim;
         this.isMoving = state.isMoving || this.isMoving;
         this.displayLoop = state.displayLoop || this.displayLoop;
@@ -142,9 +159,7 @@ export abstract class DefaultPlayerState extends DefaultSpriteState {
     public maxRotSpeed: number = 9 / 10000;
     public minRotSpeed: number = 1 / 10000;
 
-    public stateUpdate(hint?: string[]): void {
-        console.log('Player state update:', hint);
-    }
+    public stateUpdate(_action: string, _hint?: string[]): void {}
 
     public deltaRotSpeed(rotSpeed: number): number {
         return rotSpeed * (1 + 1 / 1000000);

+ 173 - 7
examples/game/res/frontend/src/display/engine.ts

@@ -20,9 +20,12 @@ export class MainDisplayController {
 
     protected player: PlayerState = {} as PlayerState;
     protected sprites: SpriteState[] = [];
+    protected overlayData: string[] = [];
 
     private animationFrame: number = 0;
-    //private lastRenderCycleTime: number = 0;
+
+    private isSpectatorMode: boolean = false;
+    protected showHelp: boolean = false;
 
     constructor(canvasElementId: string, options: EngineOptions) {
         this.options = options;
@@ -60,6 +63,12 @@ export class MainDisplayController {
      */
     public registerEventHandlers(): void {
         document.onkeydown = (e) => {
+            // Handle display control
+
+            if (e.code == 'F1') {
+                this.showHelp = !this.showHelp;
+            }
+
             this.options.eventHandler.onkeydown(this.player, e);
         };
         document.onkeyup = (e) => {
@@ -79,10 +88,14 @@ export class MainDisplayController {
      * Start the engine.
      */
     public start(playerState: PlayerState): void {
-        this.running = true;
         this.player = playerState;
         this.registerEventHandlers();
-        this.drawLoop();
+        this.isSpectatorMode = false;
+
+        if (!this.running) {
+            this.running = true;
+            this.drawLoop();
+        }
     }
 
     /**
@@ -94,6 +107,14 @@ export class MainDisplayController {
         this.options.stopHandler();
     }
 
+    /**
+     * Run the engine in spectator mode.
+     */
+    public spectatorMode(): void {
+        this.isSpectatorMode = true;
+        this.deRegisterEventHandlers();
+    }
+
     /**
      * Add a sprite to the simulation.
      */
@@ -105,9 +126,17 @@ export class MainDisplayController {
      * Remove a sprite to the simulation.
      */
     public removeSprite(spriteState: SpriteState): void {
+        if (!spriteState) {
+            throw new Error('Trying to remove non-existing sprite state');
+        }
+
         this.sprites.splice(this.sprites.indexOf(spriteState), 1);
     }
 
+    setOverlayData(text: string[]) {
+        this.overlayData = text;
+    }
+
     /**
      * Print debug information which are cleared with every draw loop.
      */
@@ -138,6 +167,13 @@ export class MainDisplayController {
                 this.canvas.width,
                 this.canvas.height
             );
+
+            // Darken the background image a bit
+
+            this.ctx.save();
+            this.ctx.globalAlpha = 0.5;
+            this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
+            this.ctx.restore();
         }
 
         // Clear debug element if there is one
@@ -149,7 +185,10 @@ export class MainDisplayController {
         let start = new Date().getTime();
 
         this.drawSprites();
-        this.drawPlayer();
+
+        if (!this.isSpectatorMode) {
+            this.drawPlayer();
+        }
 
         // Call external handler
 
@@ -168,6 +207,8 @@ export class MainDisplayController {
             this.printDebug('FPS: ' + fps);
         }
 
+        this.drawInfoOverlay();
+
         if (this.running) {
             setTimeout(() => {
                 this.drawLoop();
@@ -175,6 +216,100 @@ export class MainDisplayController {
         }
     }
 
+    drawInfoOverlay(): void {
+        this.ctx.save();
+        this.ctx.font = '10px Arial bold';
+        this.ctx.globalAlpha = 0.6;
+
+        let textColor = '#000000';
+        this.ctx.fillStyle = '#ffbf00';
+        this.ctx.strokeStyle = '#ff8000';
+
+        if (this.isSpectatorMode) {
+            let text = [`Player: ${this.player.id}`, ...this.overlayData, ''];
+
+            let centerH = Math.floor(this.options.screenWidth / 2);
+            let centerVThird = Math.floor(this.options.screenHeight / 3);
+            let heightHalf = Math.floor((text.length * 12) / 2);
+
+            this.drawRoundRect(
+                centerH - 200,
+                centerVThird - heightHalf,
+                400,
+                heightHalf * 2 + 40,
+                10,
+                true,
+                true
+            );
+
+            this.ctx.fillStyle = textColor;
+
+            this.ctx.font = '20px Arial';
+            let gameOverText = 'Game Over';
+            this.ctx.fillText(
+                gameOverText,
+                centerH -
+                    Math.floor(this.ctx.measureText(gameOverText).width / 2),
+                centerVThird - heightHalf + 30
+            );
+
+            this.ctx.font = '10px Arial bold';
+
+            for (let i = 0; i < text.length; i++) {
+                let t = text[i].trim();
+                let twidth = this.ctx.measureText(t).width;
+
+                this.ctx.fillText(
+                    t,
+                    centerH - Math.floor(twidth / 2),
+                    centerVThird - heightHalf + 50 + i * 12
+                );
+            }
+        } else {
+            let text = [`Player: ${this.player.id}`, ...this.overlayData, ''];
+
+            if (this.showHelp) {
+                text.push('Controls:');
+                text.push('');
+                text.push('<cursor left/right>');
+                text.push('    Rotate');
+                text.push('');
+                text.push('<cursor up/down>');
+                text.push('    Accelerate/Decelerate');
+                text.push('');
+                text.push('<space / ctrl>');
+                text.push('    Shoot');
+                text.push('');
+                text.push('<shift>');
+                text.push('    Strafe');
+            } else {
+                text.push('Press F1 for help ...');
+            }
+
+            this.drawRoundRect(
+                this.options.screenWidth - 170,
+                10,
+                160,
+                13 + text.length * 10,
+                10,
+                true,
+                true
+            );
+
+            this.ctx.fillStyle = textColor;
+
+            for (let i = 0; i < text.length; i++) {
+                this.ctx.fillText(
+                    text[i],
+                    this.options.screenWidth - 165,
+                    25 + i * 10
+                );
+            }
+        }
+
+        this.ctx.restore();
+    }
+
     private lastRenderCycleTime: number = 0;
 
     // Draw the player graphics.
@@ -183,7 +318,7 @@ export class MainDisplayController {
         try {
             // Call draw routine in player state
 
-            this.player.draw(this.ctx, this.player);
+            this.player.draw(this.ctx, this.player, this.options.assets);
             return;
         } catch {}
 
@@ -228,8 +363,8 @@ export class MainDisplayController {
             try {
                 // Call draw routine in sprite state
 
-                sprite.draw(this.ctx, sprite);
-                return;
+                sprite.draw(this.ctx, sprite, this.options.assets);
+                continue;
             } catch {}
 
             this.ctx.beginPath();
@@ -258,4 +393,35 @@ export class MainDisplayController {
             this.ctx.strokeStyle = oldStrokeStyle;
         }
     }
+
+    drawRoundRect(
+        x: number,
+        y: number,
+        w: number,
+        h: number,
+        radius: number = 5,
+        fill: boolean = true,
+        stroke: boolean = true
+    ): void {
+        let r = { tl: radius, tr: radius, br: radius, bl: radius };
+
+        this.ctx.beginPath();
+        this.ctx.moveTo(x + r.tl, y);
+        this.ctx.lineTo(x + w - r.tr, y);
+        this.ctx.quadraticCurveTo(x + w, y, x + w, y + r.tr);
+        this.ctx.lineTo(x + w, y + h - r.br);
+        this.ctx.quadraticCurveTo(x + w, y + h, x + w - r.br, y + h);
+        this.ctx.lineTo(x + r.bl, y + h);
+        this.ctx.quadraticCurveTo(x, y + h, x, y + h - r.bl);
+        this.ctx.lineTo(x, y + r.tl);
+        this.ctx.quadraticCurveTo(x, y, x + r.tl, y);
+        this.ctx.closePath();
+
+        if (fill) {
+            this.ctx.fill();
+        }
+        if (stroke) {
+            this.ctx.stroke();
+        }
+    }
 }

+ 19 - 2
examples/game/res/frontend/src/display/types.ts

@@ -1,3 +1,6 @@
+import { AssetDefinition } from '../backend/asset-loader';
+import { AnimationController } from '../game/lib';
+
 /**
  * Handler to react to player events.
  */
@@ -44,6 +47,10 @@ export interface EngineOptions {
 
     screenElementWidth: number;
     screenElementHeight: number;
+
+    // Game assets
+
+    assets: Record<string, AssetDefinition>;
 }
 
 /**
@@ -55,6 +62,10 @@ export interface SpriteState {
     x: number; // Sprite x position
     y: number; // Sprite y position
 
+    kind: string; // Sprint kind
+
+    owner?: string; // Owner of this sprite
+
     dim: number; // Dimensions of the sprite (box)
 
     isMoving: boolean; // Flag if the sprite is moving or static
@@ -71,6 +82,8 @@ export interface SpriteState {
     strafe: number; // Strafing direction of sprite (-1 left, 1 right, 0 no movement)
     moveSpeed: number; // Move speed for each step
 
+    animation: AnimationController | null; // Animation controller for this sprite
+
     /**
      * Set the state from a given map structure.
      */
@@ -79,7 +92,11 @@ export interface SpriteState {
     /**
      * Draw this sprite.
      */
-    draw(ctx: CanvasRenderingContext2D, state: SpriteState): void;
+    draw(
+        ctx: CanvasRenderingContext2D,
+        state: SpriteState,
+        assets: Record<string, AssetDefinition>
+    ): void;
 }
 
 /**
@@ -93,7 +110,7 @@ export interface PlayerState extends SpriteState {
      * The player made some input and the object state has been updated. This
      * function is called to send these updates to the backend.
      */
-    stateUpdate(hint?: string[]): void;
+    stateUpdate(action: String, hint?: string[]): void;
 
     /**
      * Function to increase rotation speed.

+ 244 - 22
examples/game/res/frontend/src/frontend.ts

@@ -9,23 +9,231 @@ import { EliasDBGraphQLClient } from './backend/eliasdb-graphql';
 import { MainGameController } from './game/game-controller';
 
 import { generateRandomName, getURLParams, setURLParam } from './helper';
+import {
+    AssetDefinition,
+    AssetLoader,
+    AssetType
+} from './backend/asset-loader';
+import { playLoop } from './game/lib';
 
 export default {
+    generatePlayerName: function () {
+        return generateRandomName();
+    },
+
     start: async function (canvasId: string) {
-        // Initial values
+        const host = `${window.location.hostname}:${window.location.port}`;
+
+        let ass = new AssetLoader(host);
+
+        ass.preload(
+            [
+                {
+                    id: 'background_nebular',
+                    type: AssetType.ImageAsset,
+                    file: 'background_nebular.jpg'
+                },
+                {
+                    id: 'spaceShips_001',
+                    type: AssetType.ImageAsset,
+                    file: 'spaceShips_001.png'
+                },
+                {
+                    id: 'spaceShips_002',
+                    type: AssetType.ImageAsset,
+                    file: 'spaceShips_002.png'
+                },
+                {
+                    id: 'spaceShips_003',
+                    type: AssetType.ImageAsset,
+                    file: 'spaceShips_003.png'
+                },
+                {
+                    id: 'spaceShips_004',
+                    type: AssetType.ImageAsset,
+                    file: 'spaceShips_004.png'
+                },
+                {
+                    id: 'spaceShips_005',
+                    type: AssetType.ImageAsset,
+                    file: 'spaceShips_005.png'
+                },
+                {
+                    id: 'spaceShips_006',
+                    type: AssetType.ImageAsset,
+                    file: 'spaceShips_006.png'
+                },
+                {
+                    id: 'spaceShips_007',
+                    type: AssetType.ImageAsset,
+                    file: 'spaceShips_007.png'
+                },
+                {
+                    id: 'spaceShips_008',
+                    type: AssetType.ImageAsset,
+                    file: 'spaceShips_008.png'
+                },
+                {
+                    id: 'spaceShips_009',
+                    type: AssetType.ImageAsset,
+                    file: 'spaceShips_009.png'
+                },
+                {
+                    id: 'asteroid_001',
+                    type: AssetType.ImageAsset,
+                    file: 'asteroid_001.png'
+                },
+                {
+                    id: 'asteroid_002',
+                    type: AssetType.ImageAsset,
+                    file: 'asteroid_002.png'
+                },
+                {
+                    id: 'shot_001',
+                    type: AssetType.ImageAsset,
+                    file: 'shot_001.png'
+                },
+                {
+                    id: 'shot_002',
+                    type: AssetType.ImageAsset,
+                    file: 'shot_002.png'
+                },
+                {
+                    id: 'shot_003',
+                    type: AssetType.ImageAsset,
+                    file: 'shot_003.png'
+                },
+                {
+                    id: 'explosion_001',
+                    type: AssetType.SoundAsset,
+                    file: 'explosion_001.mp3'
+                },
+                {
+                    id: 'explosion_002',
+                    type: AssetType.SoundAsset,
+                    file: 'explosion_002.mp3'
+                },
+                {
+                    id: 'explosion_003',
+                    type: AssetType.SoundAsset,
+                    file: 'explosion_003.mp3'
+                },
+                {
+                    id: 'explosion_004',
+                    type: AssetType.SoundAsset,
+                    file: 'explosion_004.mp3'
+                },
+                {
+                    id: 'explosion_005',
+                    type: AssetType.SoundAsset,
+                    file: 'explosion_005.mp3'
+                },
+                {
+                    id: 'vanish_001',
+                    type: AssetType.SoundAsset,
+                    file: 'vanish_001.mp3'
+                },
+                {
+                    id: 'shotfired_001',
+                    type: AssetType.SoundAsset,
+                    file: 'shot_001.mp3'
+                },
+                {
+                    id: 'shotfired_002',
+                    type: AssetType.SoundAsset,
+                    file: 'shot_002.mp3'
+                },
+                {
+                    id: 'shotfired_003',
+                    type: AssetType.SoundAsset,
+                    file: 'shot_003.mp3'
+                },
+                {
+                    id: 'shotfired_004',
+                    type: AssetType.SoundAsset,
+                    file: 'shot_004.mp3'
+                },
+                {
+                    id: 'shotfired_005',
+                    type: AssetType.SoundAsset,
+                    file: 'shot_005.mp3'
+                },
+                {
+                    id: 'shotfired_006',
+                    type: AssetType.SoundAsset,
+                    file: 'shot_006.mp3'
+                },
+                {
+                    id: 'shotfired_007',
+                    type: AssetType.SoundAsset,
+                    file: 'shot_007.mp3'
+                },
+                {
+                    id: 'shotfired_008',
+                    type: AssetType.SoundAsset,
+                    file: 'shot_008.mp3'
+                },
+                {
+                    id: 'shotfired_009',
+                    type: AssetType.SoundAsset,
+                    file: 'shot_009.mp3'
+                },
+                {
+                    id: 'ship_explosion_ani_001',
+                    type: AssetType.ImageAsset,
+                    file: 'ship_explosion_001.png'
+                },
+                {
+                    id: 'ship_explosion_snd_001',
+                    type: AssetType.SoundAsset,
+                    file: 'ship_explosion_001.mp3'
+                },
+                {
+                    id: 'background_sound',
+                    type: AssetType.SoundAsset,
+                    file: 'background-sound.mp3'
+                }
+            ],
+            (assets: Record<string, AssetDefinition>) => {
+                this.startGame(canvasId, assets);
+            }
+        );
+    },
+
+    startGame: async function (
+        canvasId: string,
+        assets: Record<string, AssetDefinition>
+    ) {
+        // Ensure required parameters are present
+
+        let params = getURLParams();
+
+        if (!params.player) {
+            setURLParam('player', generateRandomName());
+            params = getURLParams();
+        }
+
+        if (!params.game) {
+            setURLParam('game', 'main');
+            params = getURLParams();
+        }
 
         const host = `${window.location.hostname}:${window.location.port}`;
-        const gameName = 'main';
+        const gameName = params.game;
+        const playerName = params.player;
 
         // Create backend client to send game specific requests
 
-        const bc = new BackendClient(host, gameName);
+        const bc = new BackendClient(host);
+        const gqlc = new EliasDBGraphQLClient(host);
 
         // Get option details
 
         const options = new GameOptions();
 
         try {
+            // Request information about the game world
+
             let res = await bc.req(
                 '/game',
                 {
@@ -38,44 +246,58 @@ export default {
 
             // Set game world related options
 
-            options.backdrop = null; // TODO Asset load: gm.backdrop
+            options.backdrop = null;
+
+            let backdropAsset = assets[gm.backdrop || ''];
+            if (backdropAsset) {
+                options.backdrop = backdropAsset.image as HTMLImageElement;
+            }
+
+            options.assets = assets;
             options.screenWidth = gm.screenWidth;
             options.screenHeight = gm.screenHeight;
             options.screenElementWidth = gm.screenElementWidth;
             options.screenElementHeight = gm.screenElementHeight;
         } catch (e) {
-            throw new Error(`Could not register: ${e}`);
+            throw new Error(`Could not get game world information: ${e}`);
         }
 
-        const ec = new EliasDBGraphQLClient(host, gameName);
         const mdc = new MainDisplayController(canvasId, options);
 
-        // TODO: Register the player and let the game controller subscribe to the state
+        try {
+            // Create the main game controller
 
-        let params = getURLParams();
+            let gc = new MainGameController(
+                gameName,
+                playerName,
+                assets,
+                mdc,
+                bc,
+                gqlc
+            );
 
-        if (!params.player) {
-            setURLParam('player', generateRandomName());
-            params = getURLParams();
-        }
+            (this as any).GameController = gc;
 
-        const playerName = params.player;
+            // Register websocket for game state updates
+
+            const ws = await bc.createSock(
+                '/gamestate',
+                {
+                    gameName,
+                    playerName
+                },
+                gc.updatePushHandler.bind(gc)
+            );
+
+            gc.setPlayerWebsocket(ws);
 
-        try {
             await bc.req('/player', {
                 player: playerName,
                 gameName
             });
 
-            const gc = new MainGameController(
-                gameName,
-                playerName,
-                mdc,
-                bc,
-                ec
-            );
+            playLoop(assets["background_sound"].audio!)
 
-            await gc.start();
         } catch (e) {
             throw new Error(`Could not register: ${e}`);
         }

+ 135 - 67
examples/game/res/frontend/src/game/game-controller.ts

@@ -1,7 +1,10 @@
 import { BackendClient } from '../backend/api-helper';
+import { AssetDefinition } from '../backend/asset-loader';
 import { EliasDBGraphQLClient } from '../backend/eliasdb-graphql';
 import { MainDisplayController } from '../display/engine';
 import { PlayerState, SpriteState } from '../display/types';
+import { stringToNumber } from '../helper';
+import { AnimationController, AnimationStyle, playOneSound } from './lib';
 import { Player, Sprite } from './objects';
 
 /**
@@ -15,104 +18,169 @@ export class MainGameController {
 
     private display: MainDisplayController;
     protected backedClient: BackendClient;
-    private graphqlClient: EliasDBGraphQLClient;
+    protected graphQLClient: EliasDBGraphQLClient;
+    private assets: Record<string, AssetDefinition>;
 
     constructor(
         gameName: string,
         playerName: string,
+        assets: Record<string, AssetDefinition>,
         display: MainDisplayController,
         backedClient: BackendClient,
-        graphqlClient: EliasDBGraphQLClient
+        graphQLClient: EliasDBGraphQLClient
     ) {
         this.gameName = gameName;
+        this.assets = assets;
         this.display = display;
         this.backedClient = backedClient;
-        this.graphqlClient = graphqlClient;
+        this.graphQLClient = graphQLClient;
 
         this.playerState = new Player(gameName, backedClient);
         this.playerState.id = playerName;
 
         this.spriteMap = {};
-    }
-
-    public async start(): Promise<void> {
-        // Kick off full update loop for sprites - this loop runs more slowly but does a full update
-
-        const fullUpdateLoop = async () => {
-            const res = await this.graphqlClient.req(
-                '{ obj { id, x, y, dim, isMoving, displayLoop, dir, rot, rotSpeed, speed, strafe, moveSpeed } }'
-            );
-
-            const objects = res.data.obj as Record<string, any>[];
 
-            for (const obj of objects) {
-                if (obj.id !== this.playerState.id) {
-                    let sprite = this.spriteMap[obj.id];
+        // Retrieve score update
 
-                    if (!sprite) {
-                        sprite = new Sprite();
-                        sprite.setState(obj);
-                        this.spriteMap[sprite.id] = sprite;
-                        this.display.addSprite(sprite);
+        this.graphQLClient.subscribe(
+            `
+        subscription {
+          score(ascending:score) {
+              key,
+              score,
+          }
+        }`,
+            (data) => {
+                let text = [];
+                let scores: any[] = data.data.score.reverse().slice(0, 10);
+                let score = 0;
+
+                text.push(``);
+                text.push(`Highscores:`);
+                text.push(``);
+
+                for (let item of scores) {
+                    text.push(`  ${item.key}`);
+                    text.push(`          ${item.score}`);
+                    if (item.key == this.playerState.id) {
+                        score = item.score;
                     }
-                } else {
-                    this.playerState.setState(obj);
                 }
-            }
 
-            window.setTimeout(fullUpdateLoop, 500);
-        };
+                text.unshift(`Score: ${score}`);
 
-        fullUpdateLoop();
-
-        // Start the periodic update
+                this.display.setOverlayData(text);
+            }
+        );
+    }
 
-        this.registerObjectSubscription();
+    public setPlayerWebsocket(ws: WebSocket) {
+        (this.playerState as Player).setWebsocket(ws);
     }
 
     /**
-     * Register the main object subscription. This opens a websocket to the
-     * server over which the server can push all updates for objects in the game world.
+     * Handler which is called every time the backend pushes an update via the
+     * websocket for game state updates.
+     *
+     * @param data Data object from the server.
      */
-    protected registerObjectSubscription(): void {
-        this.graphqlClient.subscribe(
-            `
-subscription {
-    obj() {
-        id,
-        x,
-        y,
-        dim,
-        rot
-    }
-}`,
-            (res) => {
-                const objects = res.data.obj as Record<string, any>[];
-
-                for (const obj of objects) {
-                    if (obj.id === this.playerState.id) {
-                        this.playerState.x = obj.x;
-                        this.playerState.y = obj.y;
-                        this.playerState.dim = obj.dim;
-                        this.playerState.rot = obj.rot;
-                    } else {
-                        let sprite = this.spriteMap[obj.id];
-
-                        if (sprite) {
-                            sprite.x = obj.x;
-                            sprite.y = obj.y;
-                            sprite.dim = obj.dim;
-                            sprite.rot = obj.rot;
-                        }
+    public updatePushHandler(data: any): void {
+        if (data.payload.audioEvent) {
+            let event = data.payload.audioEvent;
+
+            if (event === 'explosion') {
+                let explosionType = Math.floor(Math.random() * 5) + 1;
+                playOneSound(
+                    this.assets[`explosion_00${explosionType}`].audio!
+                );
+            } else if (event === 'vanish') {
+                 playOneSound(
+                    this.assets[`vanish_001`].audio!
+                );
+            } else if (event === 'shot') {
+                let player = data.payload.player;
+                let shotType = stringToNumber(player, 1, 9);
+                playOneSound(this.assets[`shotfired_00${shotType}`].audio!);
+            }
+
+            return;
+        }
+
+        if (data.payload.toRemovePlayerIds) {
+            for (let i of data.payload.toRemovePlayerIds) {
+                let entity: SpriteState = this.playerState;
+                let callback: () => void;
+
+                if (i === this.playerState.id) {
+                    console.log(
+                        'Sorry',
+                        this.playerState.id,
+                        'but you are gone ...'
+                    );
+                    callback = () => {
+                        this.display.spectatorMode();
+                    };
+                } else {
+                    entity = this.spriteMap[i];
+
+                    if (!entity) {
+                        continue; // Some removal messages can be send multiple times
                     }
+                    callback = () => {
+                        delete this.spriteMap[i];
+                        this.display.removeSprite(entity);
+                    };
                 }
 
-                // Start the game once we had the first update of all object coordinates
+                entity.animation = new AnimationController(
+                    this.assets['ship_explosion_ani_001'].image!,
+                    24,
+                    24,
+                    AnimationStyle.ForwardAndBackward,
+                    3,
+                    100,
+                    callback
+                );
+                playOneSound(this.assets['ship_explosion_snd_001'].audio!);
+            }
+            return;
+        }
 
-                if (!this.display.running) {
-                    this.display.start(this.playerState);
+        if (data.payload.toRemoveSpriteIds) {
+            for (let i of data.payload.toRemoveSpriteIds) {
+                let entity = this.spriteMap[i];
+
+                if (!entity) {
+                    continue; // Some removal messages can be send multiple times
                 }
+                delete this.spriteMap[i];
+
+                this.display.removeSprite(entity);
             }
-        );
+            return;
+        }
+
+        let obj = data.payload.state as Record<string, any>;
+
+        if (obj.id === this.playerState.id) {
+            this.playerState.setState(obj);
+        } else {
+            let sprite = this.spriteMap[obj.id];
+
+            if (!sprite) {
+                sprite = new Sprite();
+                sprite.setState(obj);
+                this.spriteMap[sprite.id] = sprite;
+                this.display.addSprite(sprite);
+            }
+
+            sprite.setState(obj);
+        }
+
+        // Start the game once we had the first update of all object coordinates
+
+        if (!this.display.running) {
+            this.display.start(this.playerState);
+        }
     }
 }

+ 135 - 0
examples/game/res/frontend/src/game/lib.ts

@@ -0,0 +1,135 @@
+export enum AnimationStyle {
+    Forward = 'forward',
+    ForwardAndBackward = 'forwardandbackward'
+}
+
+export class AnimationController {
+    private framesImage: HTMLImageElement;
+    private frameWidth: number;
+    private frameHeight: number;
+    private totalFrames: number;
+
+    private style: AnimationStyle;
+    private direction: boolean = true;
+
+    private sequences: number;
+    private tickTime: number;
+    private callback: () => void;
+
+    private currentFrame: number = -1;
+    private currentFrameStartTime: number = 0;
+
+    public constructor(
+        framesImage: HTMLImageElement,
+        framesWidth: number,
+        framesHeight: number,
+        style: AnimationStyle = AnimationStyle.Forward,
+        sequences: number = -1,
+        tickTime: number = 100,
+        callback: () => void = () => {}
+    ) {
+        this.framesImage = framesImage;
+        this.frameWidth = framesWidth;
+        this.frameHeight = framesHeight;
+        this.style = style;
+        this.sequences = sequences;
+        this.tickTime = tickTime;
+        this.callback = callback;
+
+        this.totalFrames = Math.floor(framesImage.width / this.frameWidth);
+    }
+
+    /**
+     * Produce the current animation frame.
+     */
+    public tick(
+        ctx: CanvasRenderingContext2D,
+        dx: number,
+        dy: number,
+        dWidth: number,
+        dHeight: number
+    ): void {
+        if (this.sequences == 0) {
+            return;
+        }
+
+        let t = Date.now();
+        if (t - this.currentFrameStartTime > this.tickTime) {
+            this.currentFrameStartTime = t;
+
+            // Time for the next frame
+
+            if (this.direction) {
+                this.currentFrame++;
+            } else {
+                this.currentFrame--;
+            }
+
+            if (
+                this.currentFrame >= this.totalFrames - 1 ||
+                this.currentFrame <= -1
+            ) {
+                if (this.currentFrame >= this.totalFrames - 1) {
+                    if (this.style === AnimationStyle.ForwardAndBackward) {
+                        this.direction = !this.direction;
+                    } else {
+                        this.currentFrame = 0;
+                    }
+                } else if (this.currentFrame < 0) {
+                    if (this.style === AnimationStyle.ForwardAndBackward) {
+                        this.direction = !this.direction;
+                        this.currentFrame = 0;
+                    } else {
+                        this.currentFrame = this.totalFrames - 1;
+                    }
+                }
+
+                if (this.sequences > 0) {
+                    this.sequences--;
+
+                    if (this.sequences == 0) {
+                        this.callback();
+                        return;
+                    }
+                }
+            }
+        }
+
+        ctx.drawImage(
+            this.framesImage,
+            this.currentFrame * this.frameWidth,
+            0,
+            this.frameWidth,
+            this.frameHeight,
+            dx,
+            dy,
+            dWidth,
+            dHeight
+        );
+    }
+}
+
+// Play a sound from a list of sound objects
+//
+export function playOneSound(sound: HTMLAudioElement[]) {
+    for (var i = 0; i < sound.length; i++) {
+        if (sound[i].paused) {
+            sound[i].play();
+            break;
+        }
+    }
+}
+
+// Play a looping sound
+//
+export async function playLoop(sound: HTMLAudioElement[]) {
+    try {
+        sound[0].loop = true
+        await sound[0].play();
+        console.log("Background!")
+    } catch {
+        setTimeout(() => {
+            playLoop(sound)
+        }, 100)
+    }
+}

+ 107 - 13
examples/game/res/frontend/src/game/objects.ts

@@ -11,6 +11,8 @@ import {
     DefaultEngineEventHandler,
     DefaultEngineOptions
 } from '../display/default';
+import { AssetDefinition } from '../backend/asset-loader';
+import { stringToNumber } from '../helper';
 
 /**
  * Concrete implementation of the engine event handler.
@@ -31,12 +33,12 @@ export class GameOptions extends DefaultEngineOptions implements EngineOptions {
     }
 
     /**
-     * Handler called after each draw (gets also the draw context)
+     * Custom draw handler called after each draw (gets also the draw context)
      */
     public drawHandler(): void {}
 
     /**
-     * Handler called once the simulation has stopped
+     * Custom handler which gets called once the simulation has stopped
      */
     public stopHandler(): void {}
 }
@@ -47,6 +49,7 @@ export class GameOptions extends DefaultEngineOptions implements EngineOptions {
 export class Player extends DefaultPlayerState implements PlayerState {
     protected gameName: string;
     protected backedClient: BackendClient;
+    protected websocket: WebSocket | null = null;
 
     constructor(gameName: string, backedClient: BackendClient) {
         super();
@@ -54,28 +57,34 @@ export class Player extends DefaultPlayerState implements PlayerState {
         this.backedClient = backedClient;
     }
 
-    stateUpdate(): void {
-        // Update the backend with all attributes which could have changed
-        // this can be optimized by using the 'hint' parameter for this function
+    setWebsocket(ws: WebSocket) {
+        this.websocket = ws;
+    }
+
+    stateUpdate(action: string, hint?: string[]): void {
+        super.stateUpdate(action, hint);
 
-        this.backedClient
-            .req('/input', {
+        if (this.websocket != null) {
+            this.backedClient.sendSockData(this.websocket, {
                 player: this.id,
                 gameName: this.gameName,
+                action,
                 state: {
                     dir: this.dir,
                     rotSpeed: this.rotSpeed,
                     speed: this.speed,
                     strafe: this.strafe
                 }
-            })
-            .catch((e) => {
-                throw e;
             });
+        }
     }
 
-    draw(ctx: CanvasRenderingContext2D, state: SpriteState): void {
-        throw new Error(`Method not implemented. ${ctx}, ${state}`);
+    public draw(
+        ctx: CanvasRenderingContext2D,
+        state: SpriteState,
+        assets: Record<string, AssetDefinition>
+    ): void {
+        drawPlayerSprite(ctx, state, assets);
     }
 }
 
@@ -83,7 +92,92 @@ export class Player extends DefaultPlayerState implements PlayerState {
  * Concrete implementation of a non-player sprite in the world.
  */
 export class Sprite extends DefaultSpriteState implements SpriteState {
-    draw(ctx: CanvasRenderingContext2D, state: SpriteState): void {
+    draw(
+        ctx: CanvasRenderingContext2D,
+        state: SpriteState,
+        assets: Record<string, AssetDefinition>
+    ): void {
+        if (state.kind === 'asteroid') {
+            ctx.save();
+            ctx.translate(state.x, state.y);
+            ctx.rotate(state.rot + (Math.PI / 2) * 3);
+            ctx.drawImage(
+                assets['asteroid_001'].image!,
+                -state.dim / 2,
+                -state.dim / 2,
+                state.dim,
+                state.dim
+            );
+            ctx.restore();
+            return;
+        } else if (state.kind === 'shot') {
+            let shotType = stringToNumber(state.owner!, 1, 3);
+            let shot_dim = 38;
+            ctx.save();
+            ctx.translate(state.x, state.y);
+            ctx.rotate(state.rot + 2 * Math.PI);
+            ctx.drawImage(
+                assets[`shot_00${shotType}`].image!,
+                -shot_dim / 2,
+                -shot_dim / 2,
+                shot_dim,
+                shot_dim
+            );
+            ctx.restore();
+            return;
+        } else if (state.kind === 'player') {
+            drawPlayerSprite(ctx, state, assets);
+            return;
+        } else {
+            console.log('Could not draw: ', state.kind);
+        }
+
         throw new Error(`Method not implemented. ${ctx}, ${state}`);
     }
 }
+
+/**
+ * Draw the sprite of a player.
+ */
+function drawPlayerSprite(
+    ctx: CanvasRenderingContext2D,
+    state: SpriteState,
+    assets: Record<string, AssetDefinition>
+) {
+    let shipType = stringToNumber(state.id, 1, 9);
+
+    ctx.save();
+    ctx.translate(state.x, state.y);
+    ctx.rotate(state.rot + (Math.PI / 2) * 3);
+
+    if (state.animation) {
+        state.animation.tick(
+            ctx,
+            -state.dim / 2,
+            -state.dim / 2,
+            state.dim,
+            state.dim
+        );
+    } else {
+        ctx.drawImage(
+            assets[`spaceShips_00${shipType}`].image!,
+            -state.dim / 2,
+            -state.dim / 2,
+            state.dim,
+            state.dim
+        );
+    }
+
+    ctx.restore();
+
+    ctx.save();
+    ctx.font = '10px Arial';
+    ctx.fillStyle = '#ffbf00';
+
+    ctx.fillText(
+        state.id,
+        state.x - Math.floor(ctx.measureText(state.id).width / 2),
+        state.y + state.dim + 5
+    );
+    ctx.restore();
+}

+ 20 - 2
examples/game/res/frontend/src/helper.ts

@@ -1,3 +1,21 @@
+/**
+ * Create a number within a given range from a given string.
+ */
+export function stringToNumber(
+    str: string,
+    minNum: number,
+    maxNum: number
+): number {
+    let ret = 0;
+    let range = maxNum - minNum + 1;
+
+    for (var i = 0; i < str.length; i++) {
+        ret += str.charCodeAt(i);
+    }
+
+    return (ret % range) + minNum;
+}
+
 /**
  * Return all URL query parameters
  */
@@ -296,7 +314,7 @@ const adjectiveList = [
     'zealous'
 ];
 
-const nounList = [
+const animalList = [
     'Aardvark',
     'Albatross',
     'Alligator',
@@ -586,7 +604,7 @@ const nounList = [
 export function generateRandomName(): string {
     let adjective =
         adjectiveList[Math.floor(Math.random() * adjectiveList.length)];
-    let noun = nounList[Math.floor(Math.random() * nounList.length)];
+    let noun = animalList[Math.floor(Math.random() * animalList.length)];
 
     adjective = adjective.charAt(0).toUpperCase() + adjective.slice(1);
 

+ 1 - 1
examples/game/res/frontend/webpack.config.js

@@ -18,7 +18,7 @@ module.exports = {
     output: {
         filename: 'frontend.js',
         path: path.resolve(__dirname, 'dist'),
-        library: 'mainDisplay',
+        library: 'astro',
         libraryTarget: 'window',
         libraryExport: 'default'
     }

+ 27 - 0
examples/game/res/frontend/world.html

@@ -0,0 +1,27 @@
+<html>
+    <head>
+        <meta charset="UTF-8" />
+        <script src="dist/frontend.js"></script>
+        <style>
+            body {
+                background-color: black;
+            }
+            .mainscreen {
+                border: solid 1px;
+                margin-left: auto;
+                margin-right: auto;
+                display: block;
+                background-color: white;
+            }
+        </style>
+    </head>
+    <body>
+        <canvas class="mainscreen" id="screen"></canvas>
+        <div id="game-debug-out"></div>
+        <script>
+            astro.start('screen').catch(function (err) {
+                console.error(err.toString());
+            });
+        </script>
+    </body>
+</html>

+ 17 - 5
examples/game/res/scripts/const.ecal

@@ -5,10 +5,22 @@ Errors := {"EntityNotFound" : "EntityNotFound", "InternalError" : "InternalError
 ErrorCodes := {"EntityNotFound" : 401, "InternalError" : 500}
 
 /*
- Node kinds
+ Object kinds
 */
-NodeKinds := {
-    /* Obj is an object in the game world */
-    "GameWorldObject" : "obj",
+ObjectKinds := {
+
+    /* Asteroid in the game */
+    "Asteroid" : "asteroid",
+
+    /* A player in the game */
+    "Player" : "player",
+
+    /* A shot in the game */
+    "Shot" : "shot",
+
     /* Conf is a configuration obj */
-"ConfigurationObject" : "conf"}
+    "ConfigurationObject" : "conf",
+
+    /* Score is a player statistic state */
+    "ScoreObject" : "score"
+}

+ 148 - 36
examples/game/res/scripts/engine.ecal

@@ -26,20 +26,34 @@ GameEngine := {
      */
     "world" : null,
 
+    /*
+     Game state
+     */
+    gameState : null,
+
+    /*
+     Active websocket connections
+     */
+    websocket : null,
+
     /*
      Constructor
      */
-    "init" : func (part, world) {
+    "init" : func (part, world, gameState, websocket) {
         this.part := part
         this.world := world
+        this.gameState := gameState
+        this.websocket := websocket
     },
 
     /*
-     updateStats updates the statistic node in the DB.*/
-    "updateStats" : func (state) {
-        state["key"] := "stats"
-        state["kind"] := "stats"
-        db.updateNode(this.part, state)
+     updateStat updates a statistic value.
+     */
+    "updateStat" : func (key, value) {
+        mutex GameStateMutex {
+            this.gameState[this.part][key] := value
+        }
+
     },
 
     /*
@@ -48,13 +62,21 @@ GameEngine := {
     "moveLoop" : func () {
         let moveLoopTime := now()
         let timeDelta := moveLoopTime - lastMoveCycleTime # Do the move
+        mutex GameStateMutex {
+            if this.gameState == null or this.gameState[this.part] == null {
+                return null
+            }
+        }
+
 
         /*
          Do a single move step with compensation for the time delta
          */
         time := now()
+
         this.move(timeDelta)
-        this.updateStats({"time_total_move" : now() - time})
+
+        this.updateStat("time_total_move", now() - time)
 
         lastMoveCycleTime := moveLoopTime
     },
@@ -69,50 +91,87 @@ GameEngine := {
          */
         let timeCorrection := timeDelta / moveRate
 
-        if math.isNaN(timeCorrection) or math.isInf(timeCorrection, 0) {
+        if math.isNaN(timeCorrection) or math.isInf(timeCorrection, 0) or timeCorrection > 10000 {
             timeCorrection := 1
         }
 
-        /*
-         Store the latest time correction
-         */
-        this.updateStats({"time_move_correction" : timeCorrection})
-
-        gq := "{ obj { key, kind, x, y, dim,  displayLoop, dir, rot, rotSpeed, speed, strafe, moveSpeed } }"
-        time := now()
-        res := db.graphQL(this.part, gq)
-        this.updateStats({"time_move_graphql" : now() - time})
+        mutex GameStateMutex {
+            this.updateStat("time_move_correction", timeCorrection)
 
-        if len(res.data.obj) > 0 {
+            entitiesToRemove := []
 
-            trans := db.newTrans()
+            /* First move things a step */
+            for [playername, obj] in this.gameState[this.part].players {
+                if not this.moveObject(timeCorrection, obj) {
+                    entitiesToRemove := add(entitiesToRemove, obj)
+                }
+                this.executeAction(obj)
+            }
 
-            time := now()
-            for obj in res.data.obj {
-                if not this.moveObject(timeCorrection, obj, trans) {
-                    0 # TODO Decide if the entity should be removed
+            for obj in this.gameState[this.part].sprites {
+                if not this.moveObject(timeCorrection, obj) {
+                    entitiesToRemove := add(entitiesToRemove, obj)
                 }
+                this.executeAction(obj)
+            }
+
+            /* Detect collisions */
+            for [playername, obj] in this.gameState[this.part].players {
+                entitiesToRemove := concat(entitiesToRemove, this.collisionDetection(obj))
+            }
+
+            for obj in this.gameState[this.part].sprites {
+                entitiesToRemove := concat(entitiesToRemove, this.collisionDetection(obj))
             }
-            this.updateStats({"time_move_update" : now() - time})
 
-            time := now()
-            db.commit(trans)
-            this.updateStats({"time_move_commit" : now() - time})
+            /* Remove things from the world */
+            if len(entitiesToRemove) > 0 {
+
+                let toRemoveSpriteIds := []
+                let toRemovePlayerIds := []
+
+                for e in entitiesToRemove {
+                    if e.kind == const.ObjectKinds.Player {
+                        log("Removing player: {{e.id}}")
+                        toRemovePlayerIds := add(toRemovePlayerIds, e.id)
+                        this.gameState[this.part].players := del(this.gameState[this.part].players, e.id)
+                    } else {
+                        toRemoveSpriteIds := add(toRemoveSpriteIds, e.id)
+                    }
+                }
+
+                for [commID, data] in this.websocket {
+                    if data.gamename == this.part {
+                        addEventAndWait("StateUpdate", "db.web.sock.msg", {"commID" : commID, "payload" : {"toRemovePlayerIds" : toRemovePlayerIds}})
+                    }
+                }
+
+                this.gameState[this.part].sprites := hlp.filter(this.gameState[this.part].sprites, func (i) {
+                    return not i.id in toRemoveSpriteIds
+                })
+
+                for [commID, data] in this.websocket {
+                    if data.gamename == this.part {
+                        addEventAndWait("StateUpdate", "db.web.sock.msg", {"commID" : commID, "payload" : {"toRemoveSpriteIds" : toRemoveSpriteIds}})
+                    }
+                }
+            }
         }
+
     },
 
     /*
      Move a specific object in the game world. Return false if the object
      should be removed from the world.
      */
-    "moveObject" : func (timeCorrection, obj, trans) {
+    "moveObject" : func (timeCorrection, obj) {
         let keepObj := true
 
         /*
          Calculate new entity coordinates
          */
         let moveStep := timeCorrection * obj.speed * obj.moveSpeed
-        let strafeStep := timeCorrection * obj.strafe * obj.moveSpeed * 20
+        let strafeStep := timeCorrection * obj.strafe * obj.moveSpeed * 0.02
 
         /*
          Forward / backward movement
@@ -160,14 +219,67 @@ GameEngine := {
             keepObj := false
         }
 
-        db.updateNode(this.part, {
-            "key" : obj.key,
-            "kind" : obj.kind,
-            "x" : obj.x,
-            "y" : obj.y,
-            "rot" : obj.rot
-        }, trans)
+        mutex WebsocketMutex {
+            for [commID, data] in this.websocket {
+                if data.gamename == this.part {
+                    res := addEventAndWait("StateUpdate", "db.web.sock.msg", {"commID" : commID, "payload" : {"state" : obj}})
+                    if len(res) > 0 {
+                        log("Removing unknown websocket", commID)
+                        del(this.websocket, commID)
+                    }
+                }
+            }
+        }
+
 
         return keepObj
+    },
+
+    /*
+     Detect collisions with other objects. Return false if the object
+     should be removed from the world.
+     */
+    "collisionDetection" : func (entity) {
+        let entitiesToRemove := []
+
+        checkCollision := func (e1, e2) {
+            let e1dh := e1.dim / 2
+            let e2dh := e2.dim / 2
+
+            return e1.x + e1dh > e2.x - e2dh and e1.x - e1dh < e2.x + e2dh and e1.y + e1dh > e2.y - e2dh and e1.y - e1dh < e2.y + e2dh
+        }
+
+        for [playername, obj] in this.gameState[this.part].players {
+            if entity.id == obj.id {
+                break
+            }
+
+            if checkCollision(entity, obj) {
+                entitiesToRemove := concat(entitiesToRemove, entity.collision(entity, obj, this), obj.collision(obj, entity, this))
+            }
+        }
+
+        for obj in this.gameState[this.part].sprites {
+            if entity.id == obj.id {
+                break
+            }
+
+            if checkCollision(entity, obj) {
+                entitiesToRemove := concat(entitiesToRemove, entity.collision(entity, obj, this), obj.collision(obj, entity, this))
+            }
+        }
+
+        return entitiesToRemove
+    },
+
+    /*
+     Execute an action for a given object.
+     */
+    "executeAction" : func (entity) {
+
+        if entity.action != null {
+            entity.doAction(entity, entity.action, this)
+            entity.action := null
+        }
     }
 }

+ 17 - 3
examples/game/res/scripts/helper.ecal

@@ -1,9 +1,9 @@
 /*
  copyMap copies a given map.
 */
-func copyMap(m) {
-    let ret := {}
-    for     [k, v] in m {
+func copyMap(m, base={}) {
+    let ret := base
+    for [k, v] in m {
         ret[k] := v
     }
     return ret
@@ -34,3 +34,17 @@ func allNodeKeys(part, kind) {
 
     return ret
 }
+
+/*
+ Create a new list from a given list with all elements that pass
+ the test implemented by the provided function.
+*/
+func filter(list, f) {
+    ret := []
+    for i in list {
+        if f(i) {
+            ret := add(ret, i)
+        }
+    }
+    return ret
+}

+ 179 - 42
examples/game/res/scripts/main.ecal

@@ -3,6 +3,16 @@ import "./const.ecal" as const
 import "./helper.ecal" as hlp
 import "./engine.ecal" as engine
 
+/*
+ GameState holds the runtime state of all active games.
+*/
+GameState := {}
+
+/*
+ Websocket holds all active websocket connections.
+*/
+Websocket := {}
+
 /*
  Get details of a game world.
 
@@ -16,7 +26,7 @@ sink GetGameWorld
     let gameWorld
 
     try {
-        gameName := event.state.query.gameName[0]
+        let gameName := event.state.query.gameName[0]
 
         if gameName != "main" {
             raise(const.Errors.EntityNotFound, "Game world {{gameName}} not found")
@@ -24,18 +34,60 @@ sink GetGameWorld
 
         gameWorld := tmpl.newGameWorld(gameName)
 
-        if db.fetchNode(gameName, gameName, const.NodeKinds.ConfigurationObject) == null {
-            db.storeNode(gameName, gameWorld)
+        mutex GameStateMutex {
+
+            if GameState[gameName] == null {
+                db.storeNode(gameName, gameWorld)
+                sprites := []
+
+                for i in range(1, 8) {
+
+                    posX := math.floor(rand() * gameWorld.screenWidth - 100) + 100
+                    posY := math.floor(rand() * gameWorld.screenHeight - 100) + 100
+                    size := math.floor(rand() * 30) + 20
+                    rot := rand() * math.Pi * 2
+
+                    sprites := add(sprites, tmpl.newAsteroid("asteroid-{{i}}", posX, posY, size, rot, 0.005))
+                }
 
-            sprite := tmpl.newSpriteNode("asteroid", 300, 300, 40, 20, 0.01)
-            db.storeNode(gameName, sprite)
+                GameState[gameName] := {
+                    "players" : {},
+                    "sprites" : sprites,
+                    "stats" : {},
+                    "world" : gameWorld
+                }
+            }
         }
+
     } except e {
         error(e)
-        db.raiseWebEventHandled({"code" : const.ErrorCodes[e.type], "body" : {"error" : e.type}})
+        db.raiseWebEventHandled({"status" : const.ErrorCodes[e.type], "body" : {"error" : e.type}})
     } otherwise {
-        db.raiseWebEventHandled({"code" : 200, "body" : {"result" : "success", "gameworld" : gameWorld}})
+        db.raiseWebEventHandled({"cstatusode" : 200, "body" : {"result" : "success", "gameworld" : gameWorld}})
+    }
+}
+
+
+/*
+ Register a new websocket connection
+
+ Endpoint: wss://<host>/db/sock/gamestate
+*/
+sink WebSocketRegister
+    kindmatch ["db.web.sock"]
+    statematch {"path" : "gamestate", "method" : "GET"}
+    priority 0
+{
+    let gameName := event.state.query.gameName[0]
+    let playerName := event.state.query.playerName[0]
+    let commID := event.state.commID
+
+    mutex WebsocketMutex {
+        Websocket[commID] := {"gamename" : gameName}
     }
+
+
+    log("Register websocket for player: ", playerName, " in game: ", gameName, " commID: ", commID)
 }
 
 
@@ -55,20 +107,35 @@ sink RegisterNewPlayer
     try {
         let playerName := event.state.bodyJSON.player
         let gameName := event.state.bodyJSON.gameName
+        let gameWorld := GameState[gameName].world
+
+        mutex GameStateMutex {
+            if GameState[gameName].players[playerName] == null {
+
+                posY := math.floor(rand() * gameWorld.screenHeight - 100) + 100
 
-        gameWorld := tmpl.DefaultGameWorld
+                GameState[gameName].players[playerName] := tmpl.newPlayer(event.state.bodyJSON.player, 20, posY)
 
-        if db.fetchNode(gameName, playerName, const.NodeKinds.GameWorldObject) == null {
-            sprite := tmpl.newSpriteNode(event.state.bodyJSON.player, 21, 21)
-            db.storeNode(gameName, sprite)
+                /*
+                 Reset any player score
+                 */
+                addEvent("changescore", "main.gamescore", {
+                    "id" : playerName,
+                    "part" : gameName,
+                    "changeFunc" : func (s) {
+                        s.score := 0
+                    }
+                })
+
+                log("Registered player: ", playerName, " for game:", gameName)
+            }
         }
 
-        log("Registered player: ", playerName, " for game:", gameName)
     } except e {
         error(e)
-        db.raiseWebEventHandled({"code" : const.ErrorCodes["InternalError"], "body" : {"error" : const.Errors.InternalError}})
+        db.raiseWebEventHandled({"status" : const.ErrorCodes["InternalError"], "body" : {"error" : const.Errors.InternalError}})
     } otherwise {
-        db.raiseWebEventHandled({"code" : 200, "body" : {
+        db.raiseWebEventHandled({"status" : 200, "body" : {
                 "result" : "success",
                 "sprite" : sprite,
                 "gameworld" : gameWorld
@@ -78,38 +145,62 @@ sink RegisterNewPlayer
 
 
 /*
- Process player input.
-
- Endpoint: /db/ecal/input
+ Handle player input - send over an established websocket connection.
 */
-sink PlayerInput
-    kindmatch ["db.web.ecal"]
-    statematch {"path" : "input", "method" : "POST"}
-    priority 10
+sink WebSocketHandler
+    kindmatch ["db.web.sock.data"]
+    statematch {"path" : "gamestate", "method" : "GET"}
+    priority 0
 {
-    let sprite
-    let gameWorld
 
     try {
-        let playerName := event.state.bodyJSON.player
-        let gameName := event.state.bodyJSON.gameName
-        let state := event.state.bodyJSON.state
+        let playerName := event.state.data.player
+        let gameName := event.state.data.gameName
+        let action := event.state.data.action
+        let state := event.state.data.state
 
-        state["key"] := playerName
-        state["kind"] := const.NodeKinds.GameWorldObject
-
-        db.updateNode(gameName, state)
+        mutex GameStateMutex {
+            if GameState[gameName].players[playerName] != null {
+                for [k, v] in state {
+                    GameState[gameName].players[playerName][k] := v
+                }
+                if not action in ["move", "stop move"] and GameState[gameName].players[playerName].action == null {
+                    GameState[gameName].players[playerName].action := action
+                }
+            } else {
+                log("Someone didn't know they were gone: ", playerName)
+                addEventAndWait("StateUpdate", "db.web.sock.msg", {"commID" : event.state.commID, "payload" : {"toRemovePlayerIds" : [playerName]}})
+            }
+        }
 
-        log("Updated player: ", playerName, " for game:", gameName, " with state:", state)
     } except e {
         error(e)
-        db.raiseWebEventHandled({"code" : const.ErrorCodes["InternalError"], "body" : {"error" : const.Errors.InternalError}})
-    } otherwise {
-        db.raiseWebEventHandled({"code" : 200, "body" : {
-                "result" : "success",
-                "sprite" : sprite,
-                "gameworld" : gameWorld
-        }})
+    }
+}
+
+
+/*
+ GameScore sink.
+*/
+sink MainGameScore
+    kindmatch ["main.gamescore"]
+    priority 100
+{
+    try {
+        let scoreObj := db.fetchNode(event.state.part, event.state.id, const.ObjectKinds.ScoreObject)
+
+        if scoreObj == null {
+            scoreObj := {
+                "key" : event.state.id,
+                "kind" : const.ObjectKinds.ScoreObject,
+                "score" : 0
+            }
+        }
+
+        event.state.changeFunc(scoreObj)
+        db.storeNode(event.state.part, scoreObj)
+    } except e {
+        error("GameScore:", e)
     }
 }
 
@@ -117,7 +208,7 @@ sink PlayerInput
 /*
  Object for main game engine.
 */
-MainGameEngine := new(engine.GameEngine, "main", tmpl.DefaultGameWorld)
+MainGameEngine := new(engine.GameEngine, "main", tmpl.DefaultGameWorld, GameState, Websocket)
 
 /*
  GameLoop sink.
@@ -129,7 +220,45 @@ sink MainGameLoop
     try {
         MainGameEngine.moveLoop()
     } except e {
-        error("Gameloop:", e)
+        error("Game loop:", e)
+    }
+}
+
+
+/*
+ Period game events loop.
+*/
+sink PeriodicGameEvents
+    kindmatch ["main.periodicgameevents"]
+    priority 100
+{
+    try {
+        mutex GameStateMutex {
+
+            for [gameName, state] in GameState {
+                let gameWorld := state.world
+
+                if len(GameState[gameName].sprites) < 10 {
+                    log("Adding more asteroids", gameWorld.screenWidth)
+                    sprites := GameState[gameName].sprites
+
+                    for i in range(1, 4) {
+
+                        posX := math.floor(rand() * gameWorld.screenWidth - 100) + 100
+                        posY := gameWorld.screenHeight - 100
+                        size := math.floor(rand() * 40) + 20
+                        rot := rand() * math.Pi * 2
+
+                        sprites := add(sprites, tmpl.newAsteroid("asteroid-{{now()}}-{{i}}", posX, posY, size, rot, 0.005))
+                    }
+
+                    GameState[gameName].sprites := sprites
+                }
+            }
+        }
+
+    } except e {
+        error("Periodic events loop:", e)
     }
 }
 
@@ -139,7 +268,15 @@ sink MainGameLoop
  must always be greater than the total time of the move loop (see the time_total_move
  stat recorded in engine.ecal).
 
- 55000 - 55 milli seconds - smooth animation calculated in the backend, frontend only needs to display
+ 35000 - 35 milli seconds - smooth animation calculated in the backend, frontend only needs to display
+
+*/
+setPulseTrigger(35000, "Main Game Loop", "main.gameloop")
+
+/*
+ Trigger periodic events in the game
+
+ 1000000 - 1 second
 
 */
-setPulseTrigger(55000, "Main Game Loop", "main.gameloop")
+setPulseTrigger(1000000, "Periodic Game Events", "main.periodicgameevents")

+ 172 - 10
examples/game/res/scripts/templates.ecal

@@ -8,27 +8,27 @@ func newGameWorld(name) {
     let ret := hlp.copyMap(DefaultGameWorld)
 
     ret["key"] := name
-    ret["kind"] := const.NodeKinds.ConfigurationObject
+    ret["kind"] := const.ObjectKinds.ConfigurationObject
 
     return ret
 }
 
 DefaultGameWorld := {
-    "backdrop" : null,
-    "screenWidth" : 640,
-    "screenHeight" : 480,
-    "screenElementWidth" : 640,
-    "screenElementHeight" : 480
+    "screenWidth" : 1280,
+    "screenHeight" : 1024,
+    "screenElementWidth" : 1280,
+    "screenElementHeight" : 1024,
+    "backdrop" : "background_nebular"
 }
 
 /*
- newSpriteNode creates a new sprite node datastructure.
+ newSpriteNode creates a new general sprite datastructure.
 */
-func newSpriteNode(id, x, y, dim=20, rot=0, speed=0) {
+func newSpriteNode(id, kind, x, y, dim=20, rot=0, speed=0) {
     let ret := hlp.copyMap(DefaultSpriteState)
 
     ret["key"] := id
-    ret["kind"] := const.NodeKinds.GameWorldObject
+    ret["kind"] := kind
 
     ret["id"] := id
     ret["x"] := x
@@ -80,5 +80,167 @@ DefaultSpriteState := {
     "strafe" : 0,
 
     /* Move speed for each step */
-    "moveSpeed" : 0.21
+    "moveSpeed" : 0.21,
+
+    /* Action handler funcion */
+    "doAction" : func (entity, action, engine) {
+    },
+
+    /* Collision handler funcion */
+    "collision" : func (entity, otherEntity) {
+        return []
+    }
+}
+
+PlayerState := {
+    "lastBounce" : 0,
+
+    /* Collision handler funcion */
+    "collision" : func (entity, otherEntity, engine) {
+        return [entity]
+    },
+
+    /* Action handler funcion */
+    "doAction" : func (entity, action, engine) {
+
+        if action == "fire" {
+            let sx := entity.x + math.cos(entity.rot) * 20
+            let sy := entity.y + math.sin(entity.rot) * 20
+            let sprites := engine.gameState[engine.part].sprites
+            let sprite := newShot("shot-{{entity.id}}-{{math.floor(rand() * 1000)}}", sx, sy, entity.rot)
+
+            sprite.displayLoop := false
+            sprite.owner := entity.id
+
+            mutex GameStateMutex {
+                engine.gameState[engine.part].sprites := add(sprites, sprite)
+            }
+
+            sendAudioEvent({"audioEvent" : "shot", "player" : entity.id}, engine)
+        }
+    }
+}
+
+/*
+ newPlayer creates a new player datastructure.
+*/
+func newPlayer(id, x, y) {
+    base := newSpriteNode(id, const.ObjectKinds.Player, x, y)
+    return hlp.copyMap(PlayerState, base)
+}
+
+ShotState := {
+    /* Collision handler funcion */
+    "collision" : func (entity, otherEntity, engine) {
+
+        if otherEntity.kind == const.ObjectKinds.Asteroid {
+            addEvent("changescore", "main.gamescore", {
+                "id" : entity.owner,
+                "part" : engine.part,
+                "changeFunc" : func (s) {
+                    s.score := s.score + 100
+                }
+            })
+        }
+
+        /* A shot colliding with anything removes the shot */
+        return [entity]
+}}
+
+/*
+ newShot creates a new shot datastructure.
+*/
+func newShot(id, x, y, rot) {
+    base := newSpriteNode(id, const.ObjectKinds.Shot, x, y, 10, rot, 0.05)
+    return hlp.copyMap(ShotState, base)
+}
+
+AsteroidState := {
+    "lastBounce" : 0,
+
+    /* Collision handler funcion */
+    "collision" : func (entity, otherEntity, engine) {
+        let ret := []
+
+        if otherEntity.kind == const.ObjectKinds.Asteroid {
+
+            /*
+             Asteroids bounce off each other
+             */
+            if now() - entity.lastBounce > 1000000 {
+
+                entity.rot := entity.rot + math.Pi
+
+                if otherEntity.kind == const.ObjectKinds.Asteroid {
+                    otherEntity.rot := otherEntity.rot + math.Pi
+                }
+
+                /* Prevent further bouncing for some time on both objects */
+                entity.lastBounce := now()
+                if otherEntity.kind == const.ObjectKinds.Asteroid {
+                    otherEntity.lastBounce := now()
+                }
+            }
+        } elif otherEntity.kind == const.ObjectKinds.Shot {
+            let sprites := engine.gameState[engine.part].sprites
+
+            let newParticleAsteroid := func (counter) {
+
+                /*
+                 Create a particle asteroid after a bigger asteroid has been shot
+                 */
+                let rot := counter % 3 * 45 + math.floor(rand() * 45)
+                let dim := math.floor(entity.dim * 0.5)
+                let radius := 10 + math.floor(rand() * 10)
+                let speed := entity.speed + math.floor(2 + rand() * 3) / 1000
+                let newX := math.floor(entity.x + math.cos(rot) * radius)
+                let newY := math.floor(entity.y + math.sin(rot) * radius)
+
+                return newAsteroid("{{entity.id}}-{{counter}}", newX, newY, 5 + dim, rot, speed)
+            }
+
+            if entity.dim > 35 {
+                for i in range(1, 4) {
+                    let sprite := newParticleAsteroid(i)
+                    sprite.lastBounce := now()
+                    sprites := add(sprites, sprite)
+                }
+
+                mutex GameStateMutex {
+                    engine.gameState[engine.part].sprites := sprites
+                }
+
+                sendAudioEvent({"audioEvent" : "explosion"}, engine)
+            } else {
+                sendAudioEvent({"audioEvent" : "vanish"}, engine)
+            }
+
+            ret := [entity]
+        }
+
+        return ret
+    },
+
+    /* Action handler funcion */
+    "doAction" : func (entity, action, engine) {
+    }
 }
+
+/*
+ newAsteroid creates a new asteroid datastructure.
+*/
+func newAsteroid(id, x, y, dim=20, rot=0, speed=0) {
+    base := newSpriteNode(id, const.ObjectKinds.Asteroid, x, y, dim, rot, speed)
+    return hlp.copyMap(AsteroidState, base)
+}
+
+/*
+ sendAudioEvent sends an audio event to the frontend.
+*/
+func sendAudioEvent(payload, engine) {
+    for [commID, data] in engine.websocket {
+        if data.gamename == engine.part {
+            addEvent("AudioEvent", "db.web.sock.msg", {"commID" : commID, "payload" : payload})
+        }
+    }
+}

+ 5 - 3
examples/game/start.sh

@@ -3,9 +3,11 @@ cd "$(dirname "$0")"
 
 if ! [ -d "run" ]; then
   mkdir -p run/web
-#  cp -fR res/chat/* run/web
-cp -fR res/eliasdb.config.json run
-cp -fR res/scripts run
+  cp -fR res/eliasdb.config.json run
+  cp -fR res/scripts run
+  cp -fR res/frontend/*.html run/web
+  cp -fR res/frontend/assets run/web
+  cp -fR res/frontend/dist run/web
 fi
 cd run
 ../../../eliasdb server -ecal-console

+ 2 - 0
examples/game/watch_score.sh

@@ -0,0 +1,2 @@
+#!/bin/sh
+watch -n 0.5 sh ./get_score.sh

+ 0 - 2
examples/game/watch_state.sh

@@ -1,2 +0,0 @@
-#!/bin/sh
-watch -n 0.5 sh ./get_state.sh

+ 1 - 1
go.mod

@@ -4,7 +4,7 @@ go 1.12
 
 require (
 	devt.de/krotik/common v1.4.1
-	devt.de/krotik/ecal v1.4.4
+	devt.de/krotik/ecal v1.6.0
 	github.com/gorilla/websocket v1.4.1
 )