Browse Source

feat: Adding game example

Matthias Ladkau 3 years ago
parent
commit
8073f88028
36 changed files with 2366 additions and 13 deletions
  1. 5 0
      .gitignore
  2. 5 1
      api/v1/ecal.go
  3. 2 2
      ecal.md
  4. 1 2
      ecal/interpreter.go
  5. 3 0
      examples/game/doc/game.md
  6. 7 0
      examples/game/get_state.sh
  7. 17 0
      examples/game/res/.vscode/launch.json
  8. 20 0
      examples/game/res/frontend/.eslintrc.js
  9. 5 0
      examples/game/res/frontend/.prettierrc.json
  10. 1 0
      examples/game/res/frontend/dist/frontend.js
  11. 20 0
      examples/game/res/frontend/index.html
  12. 28 0
      examples/game/res/frontend/package.json
  13. 81 0
      examples/game/res/frontend/src/backend/api-helper.ts
  14. 231 0
      examples/game/res/frontend/src/backend/eliasdb-graphql.ts
  15. 16 0
      examples/game/res/frontend/src/backend/types.ts
  16. 152 0
      examples/game/res/frontend/src/display/default.ts
  17. 261 0
      examples/game/res/frontend/src/display/engine.ts
  18. 102 0
      examples/game/res/frontend/src/display/types.ts
  19. 83 0
      examples/game/res/frontend/src/frontend.ts
  20. 118 0
      examples/game/res/frontend/src/game/game-controller.ts
  21. 89 0
      examples/game/res/frontend/src/game/objects.ts
  22. 594 0
      examples/game/res/frontend/src/helper.ts
  23. 22 0
      examples/game/res/frontend/tsconfig.json
  24. 25 0
      examples/game/res/frontend/webpack.config.js
  25. 14 0
      examples/game/res/scripts/const.ecal
  26. 173 0
      examples/game/res/scripts/engine.ecal
  27. 36 0
      examples/game/res/scripts/helper.ecal
  28. 145 0
      examples/game/res/scripts/main.ecal
  29. 84 0
      examples/game/res/scripts/templates.ecal
  30. 11 0
      examples/game/start.sh
  31. 5 0
      examples/game/start_console.sh
  32. 2 0
      examples/game/watch_state.sh
  33. 3 1
      go.mod
  34. 0 2
      go.sum
  35. 4 4
      server/server.go
  36. 1 1
      server/server_test.go

+ 5 - 0
.gitignore

@@ -16,6 +16,11 @@
 /examples/data-mining/docker-images/eliasdb/eliasdb
 /examples/data-mining/docker-images/frontend/app/node_modules
 /examples/data-mining/docker-images/frontend/app/graphiql
+/examples/game/run/
+/examples/game/res/frontend/*.lock
+/examples/game/res/frontend/*-lock.json
+/examples/game/res/frontend/node_modules
+/examples/tmp
 /ssl
 /web
 /db

+ 5 - 1
api/v1/ecal.go

@@ -103,7 +103,11 @@ func (ee *ecalEndpoint) forwardRequest(w http.ResponseWriter, r *http.Request, r
 
 				query := map[interface{}]interface{}{}
 				for k, v := range r.URL.Query() {
-					query[k] = scope.ConvertJSONToECALObject(v)
+					values := make([]interface{}, 0)
+					for _, val := range v {
+						values = append(values, val)
+					}
+					query[k] = values
 				}
 
 				header := map[interface{}]interface{}{}

+ 2 - 2
ecal.md

@@ -261,7 +261,7 @@ db.graphQL("main", "query myquery($x: string) { bar(key:$x) { data }}", {
 ```
 
 #### `db.raiseGraphEventHandled()`
-When handling a graph event, notify the GraphManager of EliasDB that no further action is necessary.
+When handling a graph event, notify the GraphManager of EliasDB that no further action is necessary. This creates a special error object and should not be used inside a `try` block. When using a `try` block this can be used inside an `except` or `otherwise` block.
 
 Example:
 ```
@@ -273,7 +273,7 @@ sink mysink
 ```
 
 #### `db.raiseWebEventHandled()`
-"When handling a web event, notify the web API of EliasDB that the web request was handled.
+When handling a web event, notify the web API of EliasDB that the web request was handled. This creates a special error object and should not be used inside a `try` block. When using a `try` block this can be used inside an `except` or `otherwise` block.
 
 Example:
 ```

+ 1 - 2
ecal/interpreter.go

@@ -113,8 +113,7 @@ func (si *ScriptingInterpreter) Run() error {
 				di.EchoDebugServer = &falseFlag
 				di.Interactive = &falseFlag
 				di.BreakOnStart = &falseFlag
-				trueFlag := true
-				di.BreakOnError = &trueFlag
+				di.BreakOnError = &falseFlag
 
 				err = di.Interpret()
 

+ 3 - 0
examples/game/doc/game.md

@@ -0,0 +1,3 @@
+EliasDB Game Example
+==
+This example demonstrates a game which uses ECAL scripts for its backend.

+ 7 - 0
examples/game/get_state.sh

@@ -0,0 +1,7 @@
+#!/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"

+ 17 - 0
examples/game/res/.vscode/launch.json

@@ -0,0 +1,17 @@
+{
+    // Use IntelliSense to learn about possible attributes.
+    // Hover to view descriptions of existing attributes.
+    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+    "version": "0.2.0",
+    "configurations": [
+        {
+            "type": "ecaldebug",
+            "request": "launch",
+            "name": "Debug ECAL script with ECAL Debug Server",
+            "host": "localhost",
+            "port": 33274,
+            "dir": "${workspaceFolder}",
+            "executeOnEntry": true
+        }
+    ]
+}

+ 20 - 0
examples/game/res/frontend/.eslintrc.js

@@ -0,0 +1,20 @@
+module.exports = {
+    env: {
+        browser: true,
+        es2021: true
+    },
+    extends: ['standard'],
+    parser: '@typescript-eslint/parser',
+    parserOptions: {
+        ecmaVersion: 12,
+        sourceType: 'module'
+    },
+    plugins: ['@typescript-eslint'],
+    rules: {
+        eqeqeq: [2, 'allow-null'],
+        indent: [2, 4],
+        quotes: [2, 'single'],
+        semi: [2, 'always'],
+        'no-console': 0
+    }
+};

+ 5 - 0
examples/game/res/frontend/.prettierrc.json

@@ -0,0 +1,5 @@
+{
+    "tabWidth": 4,
+    "singleQuote": true,
+    "trailingComma": "none"
+}

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


+ 20 - 0
examples/game/res/frontend/index.html

@@ -0,0 +1,20 @@
+<html>
+    <head>
+        <meta charset="UTF-8" />
+        <script src="dist/frontend.js"></script>
+        <style>
+            .mainscreen {
+                border: solid 1px;
+            }
+        </style>
+    </head>
+    <body>
+        <canvas class="mainscreen" id="screen"></canvas>
+        <div id="game-debug-out"></div>
+        <script>
+            mainDisplay.start('screen').catch(function (err) {
+                console.error(err.toString());
+            });
+        </script>
+    </body>
+</html>

+ 28 - 0
examples/game/res/frontend/package.json

@@ -0,0 +1,28 @@
+{
+    "name": "game-frontend",
+    "displayName": "Frontend for game example",
+    "license": "MIT",
+    "scripts": {
+        "build": "webpack",
+        "watch": "webpack -w",
+        "lint": "eslint 'src/**/*.{js,ts,tsx}' --quiet",
+        "pretty": "prettier --write ."
+    },
+    "dependencies": {},
+    "devDependencies": {
+        "webpack": "^5.11.1",
+        "webpack-cli": "^4.3.0",
+        "ts-loader": "^8.0.12",
+        "@types/node": "^14.14.2",
+        "@typescript-eslint/eslint-plugin": "^4.5.0",
+        "@typescript-eslint/parser": "^4.5.0",
+        "eslint": "^7.12.0",
+        "eslint-config-standard": "^15.0.0",
+        "eslint-plugin-import": "^2.22.1",
+        "eslint-plugin-node": "^11.1.0",
+        "eslint-plugin-promise": "^4.2.1",
+        "eslint-plugin-standard": "^4.0.2",
+        "prettier": "2.1.2",
+        "typescript": "^4.0.3"
+    }
+}

+ 81 - 0
examples/game/res/frontend/src/backend/api-helper.ts

@@ -0,0 +1,81 @@
+export enum RequestMetod {
+    Post = 'post',
+    Get = 'get'
+}
+
+export class BackendClient {
+    protected host: string;
+
+    protected partition: string;
+
+    protected apiEndpoint: string;
+
+    public constructor(
+        host: string = window.location.host,
+        partition: string = 'main'
+    ) {
+        this.host = host;
+        this.partition = partition;
+        this.apiEndpoint = `https://${host}/db/ecal`;
+    }
+
+    /**
+     * Run a GraphQL query or mutation and return the response.
+     *
+     * @param query Query to run.
+     * @param variables List of variable values. The query must define these
+     *                  variables.
+     * @param operationName Name of the named operation to run. The query must
+     *                      specify this named operation.
+     * @param method  Request method to use. Get requests cannot run mutations.
+     */
+    public req(
+        path: string,
+        data: any,
+        method: RequestMetod = RequestMetod.Post
+    ): Promise<any> {
+        const http = new XMLHttpRequest();
+
+        if (method === RequestMetod.Post) {
+            http.open(method, `${this.apiEndpoint}${path}`, true);
+        } else {
+            const params = Object.keys(data)
+                .map((key) => {
+                    const val = data[key];
+                    return `${key}=${encodeURIComponent(val)}`;
+                })
+                .join('&');
+            const url = `${this.apiEndpoint}${path}?${params}`;
+
+            http.open(method, url, true);
+        }
+
+        http.setRequestHeader('content-type', 'application/json');
+
+        return new Promise(function (resolve, reject) {
+            http.onload = function () {
+                try {
+                    if (http.status === 200) {
+                        resolve(JSON.parse(http.response));
+                    } else {
+                        let err: string;
+                        try {
+                            err = JSON.parse(http.responseText)['errors'];
+                        } catch {
+                            err = http.responseText.trim();
+                        }
+                        reject(err);
+                    }
+                } catch (e) {
+                    reject(e);
+                }
+            };
+
+            if (method === RequestMetod.Post) {
+                http.send(JSON.stringify(data));
+            } else {
+                http.send();
+            }
+        });
+    }
+}

+ 231 - 0
examples/game/res/frontend/src/backend/eliasdb-graphql.ts

@@ -0,0 +1,231 @@
+/**
+ * EliasDB - JavaScript GraphQL client library
+ *
+ * Copyright 2019 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 EliasDBGraphQLClient {
+    /**
+     * Host this client is connected to.
+     */
+    protected host: string;
+
+    /**
+     * Partition this client is working on.
+     */
+    protected partition: string;
+
+    /**
+     * Websocket over which we can handle subscriptions.
+     */
+    private ws?: WebSocket;
+
+    /**
+     * EliasDB GraphQL endpoints.
+     */
+    private graphQLEndpoint: string;
+    private graphQLReadOnlyEndpoint: string;
+
+    /**
+     * List of operations to execute once the websocket connection is established.
+     */
+    private delayedOperations: { (): void }[] = [];
+
+    /**
+     * Queue of subscriptions which await an id;
+     */
+    private subscriptionQueue: { (data: any): void }[] = [];
+
+    /**
+     * Map of active subscriptions.
+     */
+    private subscriptionCallbacks: { [id: string]: { (data: any): void } } = {};
+
+    /**
+     * Createa a new EliasDB GraphQL Client.
+     *
+     * @param host Host to connect to.
+     * @param partition Partition to query.
+     */
+    public constructor(
+        host: string = window.location.host,
+        partition: string = 'main'
+    ) {
+        this.host = host;
+        this.partition = partition;
+        this.graphQLEndpoint = `https://${host}/db/v1/graphql/${partition}`;
+        this.graphQLReadOnlyEndpoint = `https://${host}/db/v1/graphql-query/${partition}`;
+    }
+
+    /**
+     * Initialize a websocket to support subscriptions.
+     */
+    private initWebsocket() {
+        const url = `wss://${this.host}/db/v1/graphql-subscriptions/${this.partition}`;
+        this.ws = new WebSocket(url);
+        this.ws.onmessage = this.message.bind(this);
+
+        this.ws.onopen = () => {
+            if (this.ws) {
+                this.ws.send(
+                    JSON.stringify({
+                        type: 'init',
+                        payload: {}
+                    })
+                );
+            }
+        };
+    }
+
+    /**
+     * Run a GraphQL query or mutation and return the response.
+     *
+     * @param query Query to run.
+     * @param variables List of variable values. The query must define these
+     *                  variables.
+     * @param operationName Name of the named operation to run. The query must
+     *                      specify this named operation.
+     * @param method  Request method to use. Get requests cannot run mutations.
+     */
+    public req(
+        query: string,
+        variables: { [key: string]: any } = {},
+        operationName: string = '',
+        method: RequestMetod = RequestMetod.Post
+    ): Promise<any> {
+        const http = new XMLHttpRequest();
+
+        const toSend: { [key: string]: any } = {
+            operationName,
+            variables,
+            query
+        };
+
+        // Send an async ajax call
+
+        if (method === RequestMetod.Post) {
+            http.open(method, this.graphQLEndpoint, true);
+        } else {
+            const params = Object.keys(toSend)
+                .map((key) => {
+                    const val =
+                        key !== 'variables'
+                            ? toSend[key]
+                            : JSON.stringify(toSend[key]);
+                    return `${key}=${encodeURIComponent(val)}`;
+                })
+                .join('&');
+            const url = `${this.graphQLReadOnlyEndpoint}?${params}`;
+
+            http.open(method, url, true);
+        }
+
+        http.setRequestHeader('content-type', 'application/json');
+
+        return new Promise(function (resolve, reject) {
+            http.onload = function () {
+                try {
+                    if (http.status === 200) {
+                        resolve(JSON.parse(http.response));
+                    } else {
+                        let err: string;
+                        try {
+                            err = JSON.parse(http.responseText)['errors'];
+                        } catch {
+                            err = http.responseText.trim();
+                        }
+                        reject(err);
+                    }
+                } catch (e) {
+                    reject(e);
+                }
+            };
+
+            if (method === RequestMetod.Post) {
+                http.send(JSON.stringify(toSend));
+            } else {
+                http.send();
+            }
+        });
+    }
+
+    /**
+     * Run a GraphQL subscription and receive updates if the data changes.
+     *
+     * @param query Query to run.
+     * @param update Update callback.
+     */
+    public subscribe(
+        query: string,
+        update: (data: any) => void,
+        variables: any = null
+    ) {
+        if (!this.ws) {
+            this.initWebsocket();
+        }
+
+        if (this.ws) {
+            const that = this;
+            const subscribeCall = function () {
+                if (that.ws) {
+                    that.ws.send(
+                        JSON.stringify({
+                            id: that.subscriptionQueue.length,
+                            query,
+                            type: 'subscription_start',
+                            variables
+                        })
+                    );
+                    that.subscriptionQueue.push(update);
+                }
+            };
+
+            if (this.ws.readyState !== WebSocket.OPEN) {
+                this.delayedOperations.push(subscribeCall);
+            } else {
+                subscribeCall();
+            }
+        }
+    }
+
+    /**
+     * Process a new websocket message.
+     *
+     * @param msg New message.
+     */
+    protected message(msg: MessageEvent) {
+        const pmsg = JSON.parse(msg.data);
+
+        if (pmsg.type == 'init_success') {
+            // Execute the delayed operations
+
+            this.delayedOperations.forEach((c) => c());
+            this.delayedOperations = [];
+        } else if (pmsg.type == 'subscription_success') {
+            const callback = this.subscriptionQueue.shift();
+            if (callback) {
+                const id = pmsg.id;
+                this.subscriptionCallbacks[id] = callback;
+            }
+        } else if (pmsg.type == 'subscription_data') {
+            const callback = this.subscriptionCallbacks[pmsg.id];
+            if (callback) {
+                callback(pmsg.payload);
+            }
+        } else if (pmsg.type == 'subscription_fail') {
+            console.error(
+                'Subscription failed: ',
+                pmsg.payload.errors.join('; ')
+            );
+        }
+    }
+}

+ 16 - 0
examples/game/res/frontend/src/backend/types.ts

@@ -0,0 +1,16 @@
+/**
+ * GameWorld object send from the backend.
+ */
+export interface GameWorld {
+    backdrop: string | null; // Backdrop image for game world
+
+    // Game world dimensions
+
+    screenWidth: number;
+    screenHeight: number;
+
+    // HTML elment dimensions (scaled if different from game world dimensions)
+
+    screenElementWidth: number;
+    screenElementHeight: number;
+}

+ 152 - 0
examples/game/res/frontend/src/display/default.ts

@@ -0,0 +1,152 @@
+import { EngineEventHandler, PlayerState } from './types';
+
+// This file contains default values for game display object
+
+/**
+ * Default handler for player events.
+ */
+export abstract class DefaultEngineEventHandler implements EngineEventHandler {
+    // Handle when the user presses a key
+    //
+    public onkeydown(state: PlayerState, e: KeyboardEvent) {
+        e = e || window.event;
+
+        switch (e.code) {
+            case 'ArrowUp':
+                state.speed += 0.006;
+                state.stateUpdate(['speed']);
+                break; // Move forward
+            case 'ArrowDown':
+                state.speed += -0.008;
+                state.stateUpdate(['speed']);
+                break; // Move backward
+            case 'ArrowRight':
+                if (e.ctrlKey || e.shiftKey) {
+                    state.strafe = 1; // Strafe right
+                    state.stateUpdate(['strafe']);
+                } else {
+                    state.dir = 1; // Rotate right
+                    if (state.rotSpeed < state.maxRotSpeed) {
+                        state.rotSpeed = state.deltaRotSpeed(state.rotSpeed);
+                        state.stateUpdate(['dir', 'rotSpeed']);
+                    } else {
+                        state.stateUpdate(['dir']);
+                    }
+                }
+                break;
+
+            case 'ArrowLeft':
+                if (e.ctrlKey || e.shiftKey) {
+                    state.strafe = -1; // Strafe left
+                    state.stateUpdate(['strafe']);
+                } else {
+                    state.dir = -1; // Rotate left
+                    if (state.rotSpeed < state.maxRotSpeed) {
+                        state.rotSpeed = state.deltaRotSpeed(state.rotSpeed);
+                        state.stateUpdate(['dir', 'rotSpeed']);
+                    } else {
+                        state.stateUpdate(['dir']);
+                    }
+                }
+                break;
+        }
+
+        this.stopBubbleEvent(e);
+    }
+
+    // Handle when the user releases a key
+    //
+    public onkeyup(state: PlayerState, e: KeyboardEvent) {
+        e = e || window.event;
+
+        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']);
+        }
+
+        this.stopBubbleEvent(e);
+    }
+
+    // Stop the bubbling of an event
+    //
+    protected stopBubbleEvent(e: KeyboardEvent) {
+        e = e || window.event;
+
+        if (e.stopPropagation) {
+            e.stopPropagation();
+        }
+        if (e.cancelBubble !== null) {
+            e.cancelBubble = true;
+        }
+    }
+}
+
+/**
+ * The player sprite in the world.
+ */
+export abstract class DefaultEngineOptions {
+    public backdrop: CanvasImageSource | null = null;
+    public screenWidth: number = 640;
+    public screenHeight: number = 480;
+    public screenElementWidth: number = 640;
+    public screenElementHeight: number = 480;
+}
+
+/**
+ * A non-player sprite in the world.
+ */
+export abstract class DefaultSpriteState {
+    public id: string = '';
+
+    public x: number = 20;
+    public y: number = 20;
+
+    public dim: number = 20;
+
+    public isMoving: boolean = true;
+
+    public displayLoop: boolean = true;
+
+    public dir: number = 0;
+    public rot: number = 0;
+    public rotSpeed: number = 9 / 100000;
+
+    public speed: number = 0;
+    public strafe: number = 0;
+    public moveSpeed: number = 0;
+
+    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.dim = state.dim || this.dim;
+        this.isMoving = state.isMoving || this.isMoving;
+        this.displayLoop = state.displayLoop || this.displayLoop;
+        this.dir = state.dir || this.dir;
+        this.rot = state.rot || this.rot;
+        this.rotSpeed = state.rotSpeed || this.rotSpeed;
+        this.speed = state.speed || this.speed;
+        this.strafe = state.strafe || this.strafe;
+        this.moveSpeed = state.moveSpeed || this.moveSpeed;
+    }
+}
+
+/**
+ * The player sprite in the world.
+ */
+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 deltaRotSpeed(rotSpeed: number): number {
+        return rotSpeed * (1 + 1 / 1000000);
+    }
+}

+ 261 - 0
examples/game/res/frontend/src/display/engine.ts

@@ -0,0 +1,261 @@
+import { EngineOptions, PlayerState, SpriteState } from './types';
+
+/**
+ * HTML element id for debug output (will only be used if it is defined)
+ */
+const debugOutputElementId = 'game-debug-out';
+
+/**
+ * Main display controller.
+ */
+export class MainDisplayController {
+    public running: boolean = false;
+
+    protected canvas: HTMLCanvasElement;
+    protected debugOutputElement: HTMLElement | null = null;
+    protected ctx: CanvasRenderingContext2D;
+    protected options: EngineOptions;
+
+    // Runtime state
+
+    protected player: PlayerState = {} as PlayerState;
+    protected sprites: SpriteState[] = [];
+
+    private animationFrame: number = 0;
+    //private lastRenderCycleTime: number = 0;
+
+    constructor(canvasElementId: string, options: EngineOptions) {
+        this.options = options;
+
+        const canvas = document.getElementById(
+            canvasElementId
+        ) as HTMLCanvasElement;
+
+        if (canvas === null) {
+            throw Error('Canvas element not found');
+        }
+
+        this.canvas = canvas;
+
+        const ctx = canvas.getContext('2d');
+
+        if (ctx === null) {
+            throw Error('Could not get canvas rendering context');
+        }
+
+        this.ctx = ctx;
+
+        this.debugOutputElement = document.getElementById(
+            debugOutputElementId
+        ) as HTMLCanvasElement;
+
+        this.canvas.width = this.options.screenWidth;
+        this.canvas.height = this.options.screenHeight;
+        this.canvas.style.width = this.options.screenElementWidth + 'px';
+        this.canvas.style.height = this.options.screenElementHeight + 'px';
+    }
+
+    /**
+     * Register event handlers for the engine.
+     */
+    public registerEventHandlers(): void {
+        document.onkeydown = (e) => {
+            this.options.eventHandler.onkeydown(this.player, e);
+        };
+        document.onkeyup = (e) => {
+            this.options.eventHandler.onkeyup(this.player, e);
+        };
+    }
+
+    /**
+     * Deregister event handlers for the engine.
+     */
+    public deRegisterEventHandlers(): void {
+        document.onkeydown = null;
+        document.onkeyup = null;
+    }
+
+    /**
+     * Start the engine.
+     */
+    public start(playerState: PlayerState): void {
+        this.running = true;
+        this.player = playerState;
+        this.registerEventHandlers();
+        this.drawLoop();
+    }
+
+    /**
+     * Stop the engine.
+     */
+    public stop(): void {
+        this.running = false;
+        this.deRegisterEventHandlers();
+        this.options.stopHandler();
+    }
+
+    /**
+     * Add a sprite to the simulation.
+     */
+    public addSprite(spriteState: SpriteState): void {
+        this.sprites.push(spriteState);
+    }
+
+    /**
+     * Remove a sprite to the simulation.
+     */
+    public removeSprite(spriteState: SpriteState): void {
+        this.sprites.splice(this.sprites.indexOf(spriteState), 1);
+    }
+
+    /**
+     * Print debug information which are cleared with every draw loop.
+     */
+    public printDebug(s: string): void {
+        if (this.debugOutputElement !== null) {
+            this.debugOutputElement.innerHTML += s + '<br>';
+        }
+    }
+
+    /**
+     * Loop function which draws the game scene.
+     */
+    protected drawLoop(): void {
+        // Calculate animation frame
+
+        this.animationFrame++;
+        this.animationFrame = this.animationFrame % 1000;
+
+        // Clear screen canvas
+
+        if (this.options.backdrop === null) {
+            this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
+        } else {
+            this.ctx.drawImage(
+                this.options.backdrop,
+                0,
+                0,
+                this.canvas.width,
+                this.canvas.height
+            );
+        }
+
+        // Clear debug element if there is one
+
+        if (this.debugOutputElement !== null) {
+            this.debugOutputElement.innerHTML = '';
+        }
+
+        let start = new Date().getTime();
+
+        this.drawSprites();
+        this.drawPlayer();
+
+        // Call external handler
+
+        this.options.drawHandler(this.ctx, this.player, this.sprites);
+
+        if (start !== 0) {
+            // Calculate FPS
+
+            let now = new Date().getTime();
+
+            const timeDelta = now - this.lastRenderCycleTime;
+            const fps = Math.floor(1000 / timeDelta);
+
+            this.lastRenderCycleTime = now;
+
+            this.printDebug('FPS: ' + fps);
+        }
+
+        if (this.running) {
+            setTimeout(() => {
+                this.drawLoop();
+            }, 20);
+        }
+    }
+
+    private lastRenderCycleTime: number = 0;
+
+    // Draw the player graphics.
+    //
+    drawPlayer(): void {
+        try {
+            // Call draw routine in player state
+
+            this.player.draw(this.ctx, this.player);
+            return;
+        } catch {}
+
+        // If no specific draw routine is specified then draw a placeholder
+
+        this.ctx.beginPath();
+        this.ctx.arc(
+            this.player.x,
+            this.player.y,
+            this.player.dim / 2,
+            0,
+            2 * Math.PI
+        );
+
+        this.ctx.moveTo(this.player.x, this.player.y);
+        this.ctx.lineTo(
+            this.player.x + Math.cos(this.player.rot) * 20,
+            this.player.y + Math.sin(this.player.rot) * 20
+        );
+        this.ctx.closePath();
+
+        this.ctx.stroke();
+
+        let oldStrokeStyle = this.ctx.strokeStyle;
+        let dimHalf = this.player.dim / 2;
+
+        this.ctx.strokeStyle = 'red';
+        this.ctx.rect(
+            this.player.x - dimHalf,
+            this.player.y - dimHalf,
+            this.player.dim,
+            this.player.dim
+        );
+        this.ctx.stroke();
+        this.ctx.strokeStyle = oldStrokeStyle;
+    }
+
+    // Draw sprite graphics
+    //
+    drawSprites(): void {
+        for (let sprite of this.sprites) {
+            try {
+                // Call draw routine in sprite state
+
+                sprite.draw(this.ctx, sprite);
+                return;
+            } catch {}
+
+            this.ctx.beginPath();
+            this.ctx.arc(sprite.x, sprite.y, sprite.dim / 2, 0, 2 * Math.PI);
+
+            this.ctx.moveTo(sprite.x, sprite.y);
+            this.ctx.lineTo(
+                sprite.x + Math.cos(sprite.rot) * 20,
+                sprite.y + Math.sin(sprite.rot) * 20
+            );
+            this.ctx.closePath();
+
+            this.ctx.stroke();
+
+            let oldStrokeStyle = this.ctx.strokeStyle;
+            let dimHalf = sprite.dim / 2;
+
+            this.ctx.strokeStyle = 'red';
+            this.ctx.rect(
+                sprite.x - dimHalf,
+                sprite.y - dimHalf,
+                sprite.dim,
+                sprite.dim
+            );
+            this.ctx.stroke();
+            this.ctx.strokeStyle = oldStrokeStyle;
+        }
+    }
+}

+ 102 - 0
examples/game/res/frontend/src/display/types.ts

@@ -0,0 +1,102 @@
+/**
+ * Handler to react to player events.
+ */
+export interface EngineEventHandler {
+    onkeydown(state: PlayerState, e: KeyboardEvent): void;
+    onkeyup(state: PlayerState, e: KeyboardEvent): void;
+}
+
+/**
+ * Options for the game.
+ */
+export interface EngineOptions {
+    /**
+     * Handler for player events
+     */
+    eventHandler: EngineEventHandler;
+
+    /**
+     * Handler called after each draw (gets also the draw context)
+     */
+    drawHandler(
+        ctx: CanvasRenderingContext2D,
+        state: PlayerState,
+        sprites: SpriteState[]
+    ): void;
+
+    /**
+     * Handler called once the simulation has stopped
+     */
+    stopHandler(): void;
+
+    /**
+     * Rendering options
+     */
+
+    backdrop: CanvasImageSource | null; // Backdrop image for game world
+
+    // Game world dimensions
+
+    screenWidth: number;
+    screenHeight: number;
+
+    // HTML elment dimensions (scaled if different from game world dimensions)
+
+    screenElementWidth: number;
+    screenElementHeight: number;
+}
+
+/**
+ * State of a sprite in the world.
+ */
+export interface SpriteState {
+    id: string; // A unique ID
+
+    x: number; // Sprite x position
+    y: number; // Sprite y position
+
+    dim: number; // Dimensions of the sprite (box)
+
+    isMoving: boolean; // Flag if the sprite is moving or static
+
+    // Flag if the sprite is kept in the display or if it should be
+    // destroyed once it is outside of the visible area
+    displayLoop: boolean;
+
+    dir: number; // Turning direction (-1 for left, 1 for right, 0 no turning)
+    rot: number; // Angle of rotation
+    rotSpeed: number; // Rotation speed for each step (in radians)
+
+    speed: number; // Moving direction (1 forward, -1 backwards, 0 no movement)
+    strafe: number; // Strafing direction of sprite (-1 left, 1 right, 0 no movement)
+    moveSpeed: number; // Move speed for each step
+
+    /**
+     * Set the state from a given map structure.
+     */
+    setState(state: Record<string, any>): void;
+
+    /**
+     * Draw this sprite.
+     */
+    draw(ctx: CanvasRenderingContext2D, state: SpriteState): void;
+}
+
+/**
+ * State of the player sprite in the world.
+ */
+export interface PlayerState extends SpriteState {
+    maxRotSpeed: number; // Max rotation speed
+    minRotSpeed: number; // Min rotation speed
+
+    /**
+     * 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;
+
+    /**
+     * Function to increase rotation speed.
+     */
+    deltaRotSpeed(rotSpeed: number): number;
+}

+ 83 - 0
examples/game/res/frontend/src/frontend.ts

@@ -0,0 +1,83 @@
+import { GameOptions } from './game/objects';
+
+import { MainDisplayController } from './display/engine';
+
+import { GameWorld } from './backend/types';
+import { BackendClient, RequestMetod } from './backend/api-helper';
+import { EliasDBGraphQLClient } from './backend/eliasdb-graphql';
+
+import { MainGameController } from './game/game-controller';
+
+import { generateRandomName, getURLParams, setURLParam } from './helper';
+
+export default {
+    start: async function (canvasId: string) {
+        // Initial values
+
+        const host = `${window.location.hostname}:${window.location.port}`;
+        const gameName = 'main';
+
+        // Create backend client to send game specific requests
+
+        const bc = new BackendClient(host, gameName);
+
+        // Get option details
+
+        const options = new GameOptions();
+
+        try {
+            let res = await bc.req(
+                '/game',
+                {
+                    gameName
+                },
+                RequestMetod.Get
+            );
+
+            const gm = res.gameworld as GameWorld;
+
+            // Set game world related options
+
+            options.backdrop = null; // TODO Asset load: gm.backdrop
+            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}`);
+        }
+
+        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
+
+        let params = getURLParams();
+
+        if (!params.player) {
+            setURLParam('player', generateRandomName());
+            params = getURLParams();
+        }
+
+        const playerName = params.player;
+
+        try {
+            await bc.req('/player', {
+                player: playerName,
+                gameName
+            });
+
+            const gc = new MainGameController(
+                gameName,
+                playerName,
+                mdc,
+                bc,
+                ec
+            );
+
+            await gc.start();
+        } catch (e) {
+            throw new Error(`Could not register: ${e}`);
+        }
+    }
+};

+ 118 - 0
examples/game/res/frontend/src/game/game-controller.ts

@@ -0,0 +1,118 @@
+import { BackendClient } from '../backend/api-helper';
+import { EliasDBGraphQLClient } from '../backend/eliasdb-graphql';
+import { MainDisplayController } from '../display/engine';
+import { PlayerState, SpriteState } from '../display/types';
+import { Player, Sprite } from './objects';
+
+/**
+ * Main game controller.
+ */
+export class MainGameController {
+    protected gameName: string; // Name of the game
+
+    protected playerState: PlayerState;
+    protected spriteMap: Record<string, SpriteState>;
+
+    private display: MainDisplayController;
+    protected backedClient: BackendClient;
+    private graphqlClient: EliasDBGraphQLClient;
+
+    constructor(
+        gameName: string,
+        playerName: string,
+        display: MainDisplayController,
+        backedClient: BackendClient,
+        graphqlClient: EliasDBGraphQLClient
+    ) {
+        this.gameName = gameName;
+        this.display = display;
+        this.backedClient = backedClient;
+        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];
+
+                    if (!sprite) {
+                        sprite = new Sprite();
+                        sprite.setState(obj);
+                        this.spriteMap[sprite.id] = sprite;
+                        this.display.addSprite(sprite);
+                    }
+                } else {
+                    this.playerState.setState(obj);
+                }
+            }
+
+            window.setTimeout(fullUpdateLoop, 500);
+        };
+
+        fullUpdateLoop();
+
+        // Start the periodic update
+
+        this.registerObjectSubscription();
+    }
+
+    /**
+     * 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.
+     */
+    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;
+                        }
+                    }
+                }
+
+                // Start the game once we had the first update of all object coordinates
+
+                if (!this.display.running) {
+                    this.display.start(this.playerState);
+                }
+            }
+        );
+    }
+}

+ 89 - 0
examples/game/res/frontend/src/game/objects.ts

@@ -0,0 +1,89 @@
+import { BackendClient } from '../backend/api-helper';
+import {
+    PlayerState,
+    SpriteState,
+    EngineOptions,
+    EngineEventHandler
+} from '../display/types';
+import {
+    DefaultPlayerState,
+    DefaultSpriteState,
+    DefaultEngineEventHandler,
+    DefaultEngineOptions
+} from '../display/default';
+
+/**
+ * Concrete implementation of the engine event handler.
+ */
+export class GameEventHandler
+    extends DefaultEngineEventHandler
+    implements EngineEventHandler {}
+
+/**
+ * Concrete implementation of the engine options.
+ */
+export class GameOptions extends DefaultEngineOptions implements EngineOptions {
+    public eventHandler: GameEventHandler;
+
+    constructor() {
+        super();
+        this.eventHandler = new GameEventHandler();
+    }
+
+    /**
+     * Handler called after each draw (gets also the draw context)
+     */
+    public drawHandler(): void {}
+
+    /**
+     * Handler called once the simulation has stopped
+     */
+    public stopHandler(): void {}
+}
+
+/**
+ * Concrete implementation of the player sprite in the world.
+ */
+export class Player extends DefaultPlayerState implements PlayerState {
+    protected gameName: string;
+    protected backedClient: BackendClient;
+
+    constructor(gameName: string, backedClient: BackendClient) {
+        super();
+        this.gameName = gameName;
+        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
+
+        this.backedClient
+            .req('/input', {
+                player: this.id,
+                gameName: this.gameName,
+                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}`);
+    }
+}
+
+/**
+ * Concrete implementation of a non-player sprite in the world.
+ */
+export class Sprite extends DefaultSpriteState implements SpriteState {
+    draw(ctx: CanvasRenderingContext2D, state: SpriteState): void {
+        throw new Error(`Method not implemented. ${ctx}, ${state}`);
+    }
+}

+ 594 - 0
examples/game/res/frontend/src/helper.ts

@@ -0,0 +1,594 @@
+/**
+ * Return all URL query parameters
+ */
+export function getURLParams() {
+    const re = /[?&]?([^=]+)=([^&]*)/g;
+    const qs = document.location.search.split('+').join(' ');
+    let params: Record<string, string> = {};
+    let tokens;
+
+    while ((tokens = re.exec(qs))) {
+        params[decodeURIComponent(tokens[1])] = decodeURIComponent(tokens[2]);
+    }
+
+    return params;
+}
+
+/**
+ * Set a URL parameter without refreshing the page.
+ */
+export function setURLParam(paramName: string, paramValue: string) {
+    let url = window.location.href;
+
+    if (url.indexOf(paramName + '=') >= 0) {
+        var prefix = url.substring(0, url.indexOf(paramName)),
+            suffix = url.substring(url.indexOf(paramName));
+
+        suffix = suffix.substring(suffix.indexOf('=') + 1);
+        suffix =
+            suffix.indexOf('&') >= 0
+                ? suffix.substring(suffix.indexOf('&'))
+                : '';
+
+        url = prefix + paramName + '=' + paramValue + suffix;
+    } else {
+        if (url.indexOf('?') < 0) {
+            url += '?' + paramName + '=' + paramValue;
+        } else {
+            url += '&' + paramName + '=' + paramValue;
+        }
+    }
+
+    window.history.pushState(null, document.title, url);
+}
+
+/**
+ * Remove URL parameter without refreshing the page.
+ */
+export function removeURLParam(paramName: string) {
+    let url = window.location.href,
+        urlparts = url.split('?');
+
+    if (urlparts.length >= 2) {
+        var prefix = paramName + '=',
+            pars = urlparts[1].split(/[&;]/g);
+
+        for (var i = pars.length - 1; i >= 0; i--) {
+            if (pars[i].lastIndexOf(prefix, 0) !== -1) {
+                pars.splice(i, 1);
+            }
+        }
+
+        url = urlparts[0] + (pars.length > 0 ? '?' + pars.join('&') : '');
+
+        window.history.pushState(null, document.title, url);
+    }
+}
+
+const adjectiveList = [
+    'adorable',
+    'adventurous',
+    'aggressive',
+    'agreeable',
+    'alert',
+    'alive',
+    'amused',
+    'angry',
+    'annoyed',
+    'annoying',
+    'anxious',
+    'arrogant',
+    'ashamed',
+    'attractive',
+    'average',
+    'awful',
+    'bad',
+    'beautiful',
+    'better',
+    'bewildered',
+    'black',
+    'bloody',
+    'blue',
+    'blue-eyed',
+    'blushing',
+    'bored',
+    'brainy',
+    'brave',
+    'breakable',
+    'bright',
+    'busy',
+    'calm',
+    'careful',
+    'cautious',
+    'charming',
+    'cheerful',
+    'clean',
+    'clear',
+    'clever',
+    'cloudy',
+    'clumsy',
+    'colorful',
+    'combative',
+    'comfortable',
+    'concerned',
+    'condemned',
+    'confused',
+    'cooperative',
+    'courageous',
+    'crazy',
+    'creepy',
+    'crowded',
+    'cruel',
+    'curious',
+    'cute',
+    'dangerous',
+    'dark',
+    'dead',
+    'defeated',
+    'defiant',
+    'delightful',
+    'depressed',
+    'determined',
+    'different',
+    'difficult',
+    'disgusted',
+    'distinct',
+    'disturbed',
+    'dizzy',
+    'doubtful',
+    'drab',
+    'dull',
+    'eager',
+    'easy',
+    'elated',
+    'elegant',
+    'embarrassed',
+    'enchanting',
+    'encouraging',
+    'energetic',
+    'enthusiastic',
+    'envious',
+    'evil',
+    'excited',
+    'expensive',
+    'exuberant',
+    'fair',
+    'faithful',
+    'famous',
+    'fancy',
+    'fantastic',
+    'fierce',
+    'filthy',
+    'fine',
+    'foolish',
+    'fragile',
+    'frail',
+    'frantic',
+    'friendly',
+    'frightened',
+    'funny',
+    'gentle',
+    'gifted',
+    'glamorous',
+    'gleaming',
+    'glorious',
+    'good',
+    'gorgeous',
+    'graceful',
+    'grieving',
+    'grotesque',
+    'grumpy',
+    'handsome',
+    'happy',
+    'healthy',
+    'helpful',
+    'helpless',
+    'hilarious',
+    'homeless',
+    'homely',
+    'horrible',
+    'hungry',
+    'hurt',
+    'ill',
+    'important',
+    'impossible',
+    'inexpensive',
+    'innocent',
+    'inquisitive',
+    'itchy',
+    'jealous',
+    'jittery',
+    'jolly',
+    'joyous',
+    'kind',
+    'lazy',
+    'light',
+    'lively',
+    'lonely',
+    'long',
+    'lovely',
+    'lucky',
+    'magnificent',
+    'misty',
+    'modern',
+    'motionless',
+    'muddy',
+    'mushy',
+    'mysterious',
+    'nasty',
+    'naughty',
+    'nervous',
+    'nice',
+    'nutty',
+    'obedient',
+    'obnoxious',
+    'odd',
+    'old-fashioned',
+    'open',
+    'outrageous',
+    'outstanding',
+    'panicky',
+    'perfect',
+    'plain',
+    'pleasant',
+    'poised',
+    'poor',
+    'powerful',
+    'precious',
+    'prickly',
+    'proud',
+    'putrid',
+    'puzzled',
+    'quaint',
+    'real',
+    'relieved',
+    'repulsive',
+    'rich',
+    'scary',
+    'selfish',
+    'shiny',
+    'shy',
+    'silly',
+    'sleepy',
+    'smiling',
+    'smoggy',
+    'sore',
+    'sparkling',
+    'splendid',
+    'spotless',
+    'stormy',
+    'strange',
+    'stupid',
+    'successful',
+    'super',
+    'talented',
+    'tame',
+    'tasty',
+    'tender',
+    'tense',
+    'terrible',
+    'thankful',
+    'thoughtful',
+    'thoughtless',
+    'tired',
+    'tough',
+    'troubled',
+    'ugliest',
+    'ugly',
+    'uninterested',
+    'unsightly',
+    'unusual',
+    'upset',
+    'uptight',
+    'vast',
+    'victorious',
+    'vivacious',
+    'wandering',
+    'weary',
+    'wicked',
+    'wide-eyed',
+    'wild',
+    'witty',
+    'worried',
+    'worrisome',
+    'wrong',
+    'zany',
+    'zealous'
+];
+
+const nounList = [
+    'Aardvark',
+    'Albatross',
+    'Alligator',
+    'Alpaca',
+    'Anole',
+    'Ant',
+    'Anteater',
+    'Antelope',
+    'Ape',
+    'Armadillo',
+    'Baboon',
+    'Badger',
+    'Barracuda',
+    'Bat',
+    'Bear',
+    'Beaver',
+    'Bee',
+    'Binturong',
+    'Bird',
+    'Bison',
+    'Bluebird',
+    'Boar',
+    'Bobcat',
+    'Budgerigar',
+    'Buffalo',
+    'Butterfly',
+    'Camel',
+    'Capybara',
+    'Caracal',
+    'Caribou',
+    'Cassowary',
+    'Cat',
+    'Caterpillar',
+    'Cattle',
+    'Chamois',
+    'Cheetah',
+    'Chicken',
+    'Chimpanzee',
+    'Chinchilla',
+    'Chough',
+    'Coati',
+    'Cobra',
+    'Cockroach',
+    'Cod',
+    'Cormorant',
+    'Cougar',
+    'Coyote',
+    'Crab',
+    'Crane',
+    'Cricket',
+    'Crocodile',
+    'Crow',
+    'Cuckoo',
+    'Curlew',
+    'Deer',
+    'Dhole',
+    'Dingo',
+    'Dinosaur',
+    'Dog',
+    'Dogfish',
+    'Dolphin',
+    'Donkey',
+    'Dove',
+    'Dragonfly',
+    'Duck',
+    'Dugong',
+    'Dunlin',
+    'Eagle',
+    'Echidna',
+    'Eel',
+    'Eland',
+    'Elephant',
+    'Elk',
+    'Emu',
+    'Falcon',
+    'Ferret',
+    'Finch',
+    'Fish',
+    'Fisher',
+    'Flamingo',
+    'Fly',
+    'Flycatcher',
+    'Fox',
+    'Frog',
+    'Gaur',
+    'Gazelle',
+    'Gecko',
+    'Genet',
+    'Gerbil',
+    'Giant',
+    'Giraffe',
+    'Gnat',
+    'Gnu',
+    'Goat',
+    'Goldfinch',
+    'Goosander',
+    'Goose',
+    'Gorilla',
+    'Goshawk',
+    'Grasshopper',
+    'Grouse',
+    'Guanaco',
+    'Guinea',
+    'Guinea',
+    'Gull',
+    'Hamster',
+    'Hare',
+    'Hawk',
+    'Hedgehog',
+    'Hermit',
+    'Heron',
+    'Herring',
+    'Hippopotamus',
+    'Hoatzin',
+    'Hoopoe',
+    'Hornet',
+    'Horse',
+    'Human',
+    'Hummingbird',
+    'Hyena',
+    'Ibex',
+    'Ibis',
+    'Iguana',
+    'Impala',
+    'Jackal',
+    'Jaguar',
+    'Jay',
+    'Jellyfish',
+    'Jerboa',
+    'Kangaroo',
+    'Kingbird',
+    'Kingfisher',
+    'Kinkajou',
+    'Kite',
+    'Koala',
+    'Kodkod',
+    'Komodo',
+    'Kookaburra',
+    'Kouprey',
+    'Kudu',
+    'Langur',
+    'Lapwing',
+    'Lark',
+    'Lechwe',
+    'Lemur',
+    'Leopard',
+    'Lion',
+    'Lizard',
+    'Llama',
+    'Lobster',
+    'Locust',
+    'Loris',
+    'Louse',
+    'Lynx',
+    'Lyrebird',
+    'Macaque',
+    'Macaw',
+    'Magpie',
+    'Mallard',
+    'Mammoth',
+    'Manatee',
+    'Mandrill',
+    'Margay',
+    'Marmoset',
+    'Marmot',
+    'Meerkat',
+    'Mink',
+    'Mole',
+    'Mongoose',
+    'Monkey',
+    'Moose',
+    'Mosquito',
+    'Mouse',
+    'Myna',
+    'Narwhal',
+    'Newt',
+    'Nightingale',
+    'Nilgai',
+    'Ocelot',
+    'Octopus',
+    'Okapi',
+    'Oncilla',
+    'Opossum',
+    'Orangutan',
+    'Oryx',
+    'Ostrich',
+    'Otter',
+    'Ox',
+    'Owl',
+    'Oyster',
+    'Panther',
+    'Parrot',
+    'Panda',
+    'Partridge',
+    'Peafowl',
+    'Penguin',
+    'Pheasant',
+    'Pig',
+    'Pigeon',
+    'Pika',
+    'Polar bear',
+    'Pony',
+    'Porcupine',
+    'Porpoise',
+    'Prairie',
+    'Pug',
+    'Quail',
+    'Quelea',
+    'Quetzal',
+    'Rabbit',
+    'Raccoon',
+    'Ram',
+    'Rat',
+    'Raven',
+    'Red deer',
+    'Red panda',
+    'Reindeer',
+    'Rhea',
+    'Rhinoceros',
+    'Rook',
+    'Saki',
+    'Salamander',
+    'Salmon',
+    'Sand dollar',
+    'Sandpiper',
+    'Sardine',
+    'Sassaby',
+    'Sea lion',
+    'Seahorse',
+    'Seal',
+    'Serval',
+    'Shark',
+    'Sheep',
+    'Shrew',
+    'Shrike',
+    'Siamang',
+    'Skink',
+    'Skipper',
+    'Skunk',
+    'Sloth',
+    'Snail',
+    'Snake  ',
+    'Spider  ',
+    'Spoonbill',
+    'Squid',
+    'Squirrel',
+    'Starling',
+    'Stilt',
+    'Swan',
+    'Tamarin',
+    'Tapir',
+    'Tarsier',
+    'Termite',
+    'Thrush',
+    'Tiger',
+    'Toad',
+    'Topi',
+    'Toucan',
+    'Turaco',
+    'Turkey',
+    'Turtle',
+    'Vicuña',
+    'Vinegaroon',
+    'Viper',
+    'Vulture',
+    'Wallaby',
+    'Walrus',
+    'Wasp',
+    'Water buffalo',
+    'Waxwing',
+    'Weasel',
+    'Whale',
+    'Wobbegong',
+    'Wolf',
+    'Wolverine',
+    'Wombat',
+    'Woodpecker',
+    'Worm',
+    'Wren',
+    'Yak',
+    'Zebra'
+];
+
+/**
+ * Generate a random funny name.
+ */
+export function generateRandomName(): string {
+    let adjective =
+        adjectiveList[Math.floor(Math.random() * adjectiveList.length)];
+    let noun = nounList[Math.floor(Math.random() * nounList.length)];
+
+    adjective = adjective.charAt(0).toUpperCase() + adjective.slice(1);
+
+    return `${adjective}${noun}`;
+}

+ 22 - 0
examples/game/res/frontend/tsconfig.json

@@ -0,0 +1,22 @@
+{
+    "compilerOptions": {
+        "module": "commonjs",
+        "target": "es6",
+        "outDir": "out",
+        "lib": ["es6", "dom"],
+        "sourceMap": false,
+        "rootDir": "src",
+        "strict": true,
+        "noImplicitReturns": true,
+        "noFallthroughCasesInSwitch": true,
+        "noUnusedParameters": true,
+        "noImplicitAny": true,
+        "removeComments": true,
+        "noUnusedLocals": true,
+        "noImplicitThis": true,
+        "inlineSourceMap": false,
+        "preserveConstEnums": true,
+        "strictNullChecks": true
+    },
+    "exclude": ["node_modules", "tmp"]
+}

+ 25 - 0
examples/game/res/frontend/webpack.config.js

@@ -0,0 +1,25 @@
+const path = require('path');
+
+module.exports = {
+    mode: 'production',
+    entry: './src/frontend.ts',
+    module: {
+        rules: [
+            {
+                test: /\.tsx?$/,
+                use: 'ts-loader',
+                exclude: /node_modules/
+            }
+        ]
+    },
+    resolve: {
+        extensions: ['.tsx', '.ts', '.js']
+    },
+    output: {
+        filename: 'frontend.js',
+        path: path.resolve(__dirname, 'dist'),
+        library: 'mainDisplay',
+        libraryTarget: 'window',
+        libraryExport: 'default'
+    }
+};

+ 14 - 0
examples/game/res/scripts/const.ecal

@@ -0,0 +1,14 @@
+/*
+ Errors types used in the backend
+*/
+Errors := {"EntityNotFound" : "EntityNotFound", "InternalError" : "InternalError"}
+ErrorCodes := {"EntityNotFound" : 401, "InternalError" : 500}
+
+/*
+ Node kinds
+*/
+NodeKinds := {
+    /* Obj is an object in the game world */
+    "GameWorldObject" : "obj",
+    /* Conf is a configuration obj */
+"ConfigurationObject" : "conf"}

+ 173 - 0
examples/game/res/scripts/engine.ecal

@@ -0,0 +1,173 @@
+import "./const.ecal" as const
+import "./helper.ecal" as hlp
+
+/*
+ Constant rate for moving. The higher the less movement we do in a given time.
+*/
+moveRate := 30
+
+/*
+ Time the move loop was executed last (used for time correction)
+*/
+lastMoveCycleTime := 0
+
+/*
+ Game engine object which moves objects in a game world.
+*/
+GameEngine := {
+
+    /*
+     Partition to manage
+     */
+    "part" : null,
+
+    /*
+     Game world
+     */
+    "world" : null,
+
+    /*
+     Constructor
+     */
+    "init" : func (part, world) {
+        this.part := part
+        this.world := world
+    },
+
+    /*
+     updateStats updates the statistic node in the DB.*/
+    "updateStats" : func (state) {
+        state["key"] := "stats"
+        state["kind"] := "stats"
+        db.updateNode(this.part, state)
+    },
+
+    /*
+     moveLoop handles object movement in the game world.
+     */
+    "moveLoop" : func () {
+        let moveLoopTime := now()
+        let timeDelta := moveLoopTime - lastMoveCycleTime # Do the move
+
+        /*
+         Do a single move step with compensation for the time delta
+         */
+        time := now()
+        this.move(timeDelta)
+        this.updateStats({"time_total_move" : now() - time})
+
+        lastMoveCycleTime := moveLoopTime
+    },
+
+    /*
+     move calculates one move step
+     */
+    "move" : func (timeDelta) {
+
+        /*
+         Calculate a correction multiplier for the time lag
+         */
+        let timeCorrection := timeDelta / moveRate
+
+        if math.isNaN(timeCorrection) or math.isInf(timeCorrection, 0) {
+            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})
+
+        if len(res.data.obj) > 0 {
+
+            trans := db.newTrans()
+
+            time := now()
+            for obj in res.data.obj {
+                if not this.moveObject(timeCorrection, obj, trans) {
+                    0 # TODO Decide if the entity should be removed
+                }
+            }
+            this.updateStats({"time_move_update" : now() - time})
+
+            time := now()
+            db.commit(trans)
+            this.updateStats({"time_move_commit" : now() - time})
+        }
+    },
+
+    /*
+     Move a specific object in the game world. Return false if the object
+     should be removed from the world.
+     */
+    "moveObject" : func (timeCorrection, obj, trans) {
+        let keepObj := true
+
+        /*
+         Calculate new entity coordinates
+         */
+        let moveStep := timeCorrection * obj.speed * obj.moveSpeed
+        let strafeStep := timeCorrection * obj.strafe * obj.moveSpeed * 20
+
+        /*
+         Forward / backward movement
+         */
+        let newX := obj.x + math.cos(obj.rot) * moveStep
+        let newY := obj.y + math.sin(obj.rot) * moveStep
+
+        /*
+         Left / right strafe movement
+         */
+        newX := newX - math.sin(obj.rot) * strafeStep
+        newY := newY + math.cos(obj.rot) * strafeStep
+
+        /*
+         Rotate the entity
+         */
+        obj.rot := obj.rot + timeCorrection * obj.dir * obj.rotSpeed
+
+        obj.x := math.floor(newX)
+        obj.y := math.floor(newY)
+
+        /*
+         Ensure the entity does not move outside the boundaries
+         */
+        if obj.displayLoop {
+            hmin := 0 - obj.dim - 20
+            hmax := this.world.screenWidth + obj.dim + 20
+
+            if obj.x > hmax {
+                obj.x := 0 - obj.dim - 10
+            } elif obj.x < hmin {
+                obj.x := this.world.screenWidth + obj.dim + 10
+            }
+
+            vmin := 0 - obj.dim - 20
+            vmax := this.world.screenHeight + obj.dim + 20
+
+            if obj.y > vmax {
+                obj.y := 0 - obj.dim - 10
+            } elif obj.y < vmin {
+                obj.y := this.world.screenHeight + obj.dim + 10
+            }
+        } elif obj.x > this.world.screenWidth or obj.x < 0 or obj.y > this.world.screenHeight or obj.y < 0 {
+
+            keepObj := false
+        }
+
+        db.updateNode(this.part, {
+            "key" : obj.key,
+            "kind" : obj.kind,
+            "x" : obj.x,
+            "y" : obj.y,
+            "rot" : obj.rot
+        }, trans)
+
+        return keepObj
+    }
+}

+ 36 - 0
examples/game/res/scripts/helper.ecal

@@ -0,0 +1,36 @@
+/*
+ copyMap copies a given map.
+*/
+func copyMap(m) {
+    let ret := {}
+    for     [k, v] in m {
+        ret[k] := v
+    }
+    return ret
+}
+
+/*
+ max returns the maximum of two numbers.
+*/
+func max(a, b) {
+    if a > b {
+        return a
+    }
+    return b
+}
+
+/*
+ allNodeKeys returns the keys of all nodes of a certain kind.
+*/
+func allNodeKeys(part, kind) {
+    let ret := []
+    let res := db.graphQL("main", "{ {{kind}} { key } }", {"kind" : kind})
+
+    if len(res.data[kind]) > 0 {
+        for o in res.data[kind] {
+            ret := add(ret, o.key)
+        }
+    }
+
+    return ret
+}

+ 145 - 0
examples/game/res/scripts/main.ecal

@@ -0,0 +1,145 @@
+import "./templates.ecal" as tmpl
+import "./const.ecal" as const
+import "./helper.ecal" as hlp
+import "./engine.ecal" as engine
+
+/*
+ Get details of a game world.
+
+ Endpoint: /db/ecal/game
+*/
+sink GetGameWorld
+    kindmatch ["db.web.ecal"]
+    statematch {"path" : "game", "method" : "GET"}
+    priority 10
+{
+    let gameWorld
+
+    try {
+        gameName := event.state.query.gameName[0]
+
+        if gameName != "main" {
+            raise(const.Errors.EntityNotFound, "Game world {{gameName}} not found")
+        }
+
+        gameWorld := tmpl.newGameWorld(gameName)
+
+        if db.fetchNode(gameName, gameName, const.NodeKinds.ConfigurationObject) == null {
+            db.storeNode(gameName, gameWorld)
+
+            sprite := tmpl.newSpriteNode("asteroid", 300, 300, 40, 20, 0.01)
+            db.storeNode(gameName, sprite)
+        }
+    } except e {
+        error(e)
+        db.raiseWebEventHandled({"code" : const.ErrorCodes[e.type], "body" : {"error" : e.type}})
+    } otherwise {
+        db.raiseWebEventHandled({"code" : 200, "body" : {"result" : "success", "gameworld" : gameWorld}})
+    }
+}
+
+
+/*
+ Register a new player.
+
+ Endpoint: /db/ecal/player
+*/
+sink RegisterNewPlayer
+    kindmatch ["db.web.ecal"]
+    statematch {"path" : "player", "method" : "POST"}
+    priority 10
+{
+    let sprite
+    let gameWorld
+
+    try {
+        let playerName := event.state.bodyJSON.player
+        let gameName := event.state.bodyJSON.gameName
+
+        gameWorld := tmpl.DefaultGameWorld
+
+        if db.fetchNode(gameName, playerName, const.NodeKinds.GameWorldObject) == null {
+            sprite := tmpl.newSpriteNode(event.state.bodyJSON.player, 21, 21)
+            db.storeNode(gameName, sprite)
+        }
+
+        log("Registered player: ", playerName, " for game:", gameName)
+    } 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
+        }})
+    }
+}
+
+
+/*
+ Process player input.
+
+ Endpoint: /db/ecal/input
+*/
+sink PlayerInput
+    kindmatch ["db.web.ecal"]
+    statematch {"path" : "input", "method" : "POST"}
+    priority 10
+{
+    let sprite
+    let gameWorld
+
+    try {
+        let playerName := event.state.bodyJSON.player
+        let gameName := event.state.bodyJSON.gameName
+        let state := event.state.bodyJSON.state
+
+        state["key"] := playerName
+        state["kind"] := const.NodeKinds.GameWorldObject
+
+        db.updateNode(gameName, state)
+
+        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
+        }})
+    }
+}
+
+
+/*
+ Object for main game engine.
+*/
+MainGameEngine := new(engine.GameEngine, "main", tmpl.DefaultGameWorld)
+
+/*
+ GameLoop sink.
+*/
+sink MainGameLoop
+    kindmatch ["main.gameloop"]
+    priority 100
+{
+    try {
+        MainGameEngine.moveLoop()
+    } except e {
+        error("Gameloop:", e)
+    }
+}
+
+
+/*
+ Trigger the main game loop in a set interval (microseconds). The interval here
+ 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
+
+*/
+setPulseTrigger(55000, "Main Game Loop", "main.gameloop")

+ 84 - 0
examples/game/res/scripts/templates.ecal

@@ -0,0 +1,84 @@
+import "./const.ecal" as const
+import "./helper.ecal" as hlp
+
+/*
+ newGameWorld creates a new game world datastructure.
+*/
+func newGameWorld(name) {
+    let ret := hlp.copyMap(DefaultGameWorld)
+
+    ret["key"] := name
+    ret["kind"] := const.NodeKinds.ConfigurationObject
+
+    return ret
+}
+
+DefaultGameWorld := {
+    "backdrop" : null,
+    "screenWidth" : 640,
+    "screenHeight" : 480,
+    "screenElementWidth" : 640,
+    "screenElementHeight" : 480
+}
+
+/*
+ newSpriteNode creates a new sprite node datastructure.
+*/
+func newSpriteNode(id, x, y, dim=20, rot=0, speed=0) {
+    let ret := hlp.copyMap(DefaultSpriteState)
+
+    ret["key"] := id
+    ret["kind"] := const.NodeKinds.GameWorldObject
+
+    ret["id"] := id
+    ret["x"] := x
+    ret["y"] := y
+
+    ret["dim"] := dim
+    ret["rot"] := rot
+    ret["speed"] := speed
+
+    return ret
+}
+
+DefaultSpriteState := {
+
+    /* A unique ID */
+    "id" : "",
+
+    /* Sprite x position */
+    "x" : 20,
+
+    /* Sprite y position */
+    "y" : 20,
+
+    /* Dimensions of the sprite (box) */
+    "dim" : 20,
+
+    /* Flag if the sprite is moving or static */
+    "isMoving" : true,
+
+    /*
+     Flag if the sprite is kept in the display or if it should be
+     destroyed once it is outside of the visible area
+     */
+    "displayLoop" : true,
+
+    /* Turning direction (-1 for left, 1 for right, 0 no turning) */
+    "dir" : 0,
+
+    /* Angle of rotation */
+    "rot" : 0,
+
+    /* Rotation speed for each step (in radians) */
+    "rotSpeed" : math.Pi / 180,
+
+    /* Moving direction (1 forward, -1 backwards, 0 no movement) */
+    "speed" : 0,
+
+    /* Strafing direction of sprite (-1 left, 1 right, 0 no movement) */
+    "strafe" : 0,
+
+    /* Move speed for each step */
+    "moveSpeed" : 0.21
+}

+ 11 - 0
examples/game/start.sh

@@ -0,0 +1,11 @@
+#!/bin/sh
+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
+fi
+cd run
+../../../eliasdb server -ecal-console

+ 5 - 0
examples/game/start_console.sh

@@ -0,0 +1,5 @@
+#!/bin/sh
+cd "$(dirname "$0")"
+
+cd run
+../../../eliasdb console

+ 2 - 0
examples/game/watch_state.sh

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

+ 3 - 1
go.mod

@@ -4,6 +4,8 @@ go 1.12
 
 require (
 	devt.de/krotik/common v1.4.1
-	devt.de/krotik/ecal v1.3.1
+	devt.de/krotik/ecal v1.4.4
 	github.com/gorilla/websocket v1.4.1
 )
+
+replace devt.de/krotik/ecal => ../ecal

+ 0 - 2
go.sum

@@ -2,7 +2,5 @@ devt.de/krotik/common v1.4.0 h1:chZihshmuv1yehyujrYyW7Yg4cgRqqIWEG2IAzhfFkA=
 devt.de/krotik/common v1.4.0/go.mod h1:X4nsS85DAxyHkwSg/Tc6+XC2zfmGeaVz+37F61+eSaI=
 devt.de/krotik/common v1.4.1 h1:gsZ9OrV+Eo4ar8Y5iLs1lAdWd8aRIcPQJ0CVxLp0uys=
 devt.de/krotik/common v1.4.1/go.mod h1:X4nsS85DAxyHkwSg/Tc6+XC2zfmGeaVz+37F61+eSaI=
-devt.de/krotik/ecal v1.3.1 h1:WuEJNHKupvTSg2+IGyNatMAaIXi9OjZ0mu1PYka9bW8=
-devt.de/krotik/ecal v1.3.1/go.mod h1:0qIx3h+EjUnStgdEUnwAeO44UluTSLcpBWXA5zEw0hQ=
 github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
 github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=

+ 4 - 4
server/server.go

@@ -449,6 +449,10 @@ func StartServerWithSingleOp(singleOperation func(*graph.Manager) bool) {
 		return
 	}
 
+	// Add to the wait group so we can wait for the shutdown
+
+	wg.Add(1)
+
 	// Read server certificate and write a fingerprint file
 
 	fpfile := filepath.Join(basepath, config.Str(config.LocationWebFolder), "fingerprint.json")
@@ -492,10 +496,6 @@ func StartServerWithSingleOp(singleOperation func(*graph.Manager) bool) {
 		hs.Shutdown()
 	}()
 
-	// Add to the wait group so we can wait for the shutdown
-
-	wg.Add(1)
-
 	print("Waiting for shutdown")
 	wg.Wait()
 

+ 1 - 1
server/server_test.go

@@ -210,7 +210,7 @@ Opening cluster state info
 Starting cluster (log history: 100)
 [Cluster] member1: Starting member manager member1 rpc server on: 127.0.0.1:9030
 Creating GraphManager instance
-Loading ECAL scripts in testdb/ecal
+Loading ECAL scripts in testdb/scripts
 Creating key (key.pem) and certificate (cert.pem) in: ssl
 Ensuring web folder: testdb/web
 Ensuring login page: testdb/web/login.html