Adding Scripting To My Game

I am writing a game in C# that allows player to navigate a marble through a world of 3D levels. This brief page describes how I am using external Typescript files to make this game more dynamic.

To define the logic for what flows in a game level, I wanted to incorporate a scripting system that uses external files that can define everything that can happen in a game level, without the need to rebuild a thing. The scripting system I choose to accomplish this is Typescript. My C# code neatly generates a set of scriptable interfaces that can be used by an external script file to make dynamic decisions about what happens in any game level. Here is a very simple example of what the scripting looks like.

Note My scripting system supports animated changes to most properties, and will be extended as I add more components and other types.


Video: Marbles Script


The Script

Here is the Typescript for the gameplay you see above. It is an external file that attaches to the game at runtime. It starts by adding a looping animation altering the rotation and y position of the airhorn doodad. A button that the ball can roll over toggles selects a new random color for the airhorn using an animated tint change. A different button resets the airhorn to its original settings, and toggles the airhorn blowing state.
function randomColor(): string {
    const channel = () => Math.floor(Math.random() * 256).toString(16).padStart(2, "0");
    return "#" + channel() + channel() + channel();
}

function isButton(obj: Component | null): obj is Button {
    return (obj != null) && obj.kind === "button";
}

function isAirHorn(obj: Component | null): obj is AirHorn {
    return (obj != null) && obj.kind === "airhorn";
}

const red = "#FF0000";
const blue = "#0000FF";

let colorButton: Button;
let blowButton: Button;
let airhorn: AirHorn;

let tint: string;
let rotation: number;
let height: number;

function colorButtonPressed(b: Button) {
    if (airhorn)
        airhorn.animate(`tint(${randomColor()}, 0.25, sine-inout)`);
    b.tint = blue;
}

function boot() {
    game.log("booting test script");
    let obj = game.find("color");
    if (isButton(obj)) {
        colorButton = obj;
        colorButton.autoRelease = true;
        colorButton.tint = red;
        colorButton.intensity = 0;
        colorButton.animate("intensity(1, 1, sine-inout, repeat-flip)");
        colorButton.onPressed = colorButtonPressed;
        colorButton.onReleased = (b) => b.tint = red;
    }
    obj = game.find("blow");
    if (isButton(obj)) {
        let time = game.time;
        obj.autoRelease = true;
        obj.onPressed = () => {
            if (airhorn == null)
                return;
            if (game.time - time < 2)
                return;
            time = game.time;
            airhorn.animate("none");
            airhorn.animate(`rotate-y(${rotation}, 0.5, sine-inout)`);
            airhorn.animate(`translate-y(${height}, 0.5, sine-inout)`);
            airhorn.animate(`tint(${tint}, 0.5, sine-inout)`);
            airhorn.blowing = !airhorn.blowing;
        };
    }
    for (const obj of game.items) {
        if (isAirHorn(obj) && airhorn == null) {
            airhorn = obj;
            tint = airhorn.tint;
            rotation = airhorn.rotation.y;
            height = airhorn.position.y;
            airhorn.animate(`rotate-y(${rotation + 360}, 4, linear, repeat`));
            airhorn.animate(`translate-y(${height + 0.5}, 2, linear, repeat-flip)`);
        }
    }
}

Definitely Typed

Below is the
game.ts.d
file that describe what is currently provide to Typescript.

interface Vector3 {
    x: number;
    y: number;
    z: number;
}

interface AnimationHandle {
    then(spec: string): AnimationHandle;
    run(callback: () => void, seconds?: number): AnimationHandle;
    reverse(delay?: number): AnimationHandle;
    repeat(delay?: number, n?: number): AnimationHandle;
    delay(seconds: number): AnimationHandle;
}

type ComponentKind = "component" | "mesh" | "doodad" | "button" | "crate" | "gear" | "spinner" | "catapult" | "inlaylight" | "airhorn" | "decal";

type EasingName =
    | "linear"
    | "sine-in" | "sine-out" | "sine-inout" | "sine-outin"
    | "quad-in" | "quad-out" | "quad-inout" | "quad-outin"
    | "cubic-in" | "cubic-out" | "cubic-inout" | "cubic-outin"
    | "quart-in" | "quart-out" | "quart-inout" | "quart-outin"
    | "quint-in" | "quint-out" | "quint-inout" | "quint-outin"
    | "expo-in" | "expo-out" | "expo-inout" | "expo-outin"
    | "circ-in" | "circ-out" | "circ-inout" | "circ-outin"
    | "elastic-in" | "elastic-out" | "elastic-inout" | "elastic-outin"
    | "back-in" | "back-out" | "back-inout" | "back-outin"
    | "bounce-in" | "bounce-out" | "bounce-inout" | "bounce-outin"
    | "spring-in" | "spring-out" | "spring-inout" | "spring-outin";

interface Component {
    readonly kind: ComponentKind;
    readonly name: string;
    parent: Component | null;
    readonly center: Vector3;
    position: Vector3;
    size: Vector3;
    rotation: Vector3;
    normal: Vector3;
    scale: Vector3;
    visible: boolean;
    animate(spec: string): AnimationHandle;
    delay(seconds: number): AnimationHandle;
    onAnimationComplete: ((obj: Component, spec: string) => void) | null;
}

interface Mesh extends Component {
    readonly kind: "mesh";
}

interface Doodad extends Component {
    tint: string;
}

interface Button extends Doodad {
    readonly kind: "button";
    intensity: number;
    autoRelease: boolean;
    onPressed: ((button: Button) => void) | null;
    onReleased: ((button: Button) => void) | null;
}

interface Crate extends Doodad { readonly kind: "crate"; }

interface Gear extends Doodad { readonly kind: "gear"; }

interface Spinner extends Doodad {
    readonly kind: "spinner";
    image: string;
    foilImage: string;
    onSpin: ((spinner: Spinner) => void) | null;
}

interface Catapult extends Doodad {
    readonly kind: "catapult";
    onBallLoaded: ((catapult: Catapult) => void) | null;
}

type InlayShape = "circle" | "triangle" | "arrow" | "box" | "star" | "hexagon" | "plus";

interface InlayLight extends Doodad {
    readonly kind: "inlaylight";
    on: boolean;
    shape: InlayShape;
    intensity: number;
    stroke: number;
    fillet: number;
    angle: number;
    pulseDuration: number;
}

interface AirHorn extends Doodad {
    readonly kind: "airhorn";
    raised: boolean;
    blowing: boolean;
    blowStrength: number;
    blowCone: number;
}

interface Decal extends Doodad {
    readonly kind: "decal";
    image: string;
    readonly imageWidth: number;
    readonly imageHeight: number;
    opacity: number;
    transition: number;
    resize(width: number): void;
}

interface DoodadKindMap {
    button: Button;
    crate: Crate;
    gear: Gear;
    spinner: Spinner;
    catapult: Catapult;
    inlaylight: InlayLight;
    airhorn: AirHorn;
    decal: Decal;
}

declare const game: {
    readonly totalTime: number;
    readonly time: number;
    readonly delta: number;
    log(message: string): void;
    setTimeout(callback: () => void, milliseconds: number): number;
    setInterval(callback: () => void, milliseconds: number): number;
    clearTimeout(id: number): void;
    clearInterval(id: number): void;
    find(name: string): Component | null;
    readonly items: Component[];
    createNullPoint(): Component;
    createDoodad(kind: K): DoodadKindMap[K];
    spawnLocation: Vector3;
};