Thomas Winged

hero image

The Riddle Bomb - Notchmeister2 Entry


Introduction

It was mid-2020. I was on a TV production gig preparing content for real-time production when I saw Notch's post on Facebook about their new shiny contest called Notchmeister 2. The headline idea was to create something extraordinary inside a cube. Being an advanced Notch artist, I decided that this was the perfect opportunity to make something out of pure enjoyment since I could make anything.

I wanted to make a game. I took inspiration from "Keep Talking and Nobody Explodes", which I played a couple of weeks earlier, and I decided to create a bombastic interactive cube puzzle. I knew it would be a heavy-logic-driven project where I would have the opportunity to explore Notch's JavaScript capabilities. Soon I comprehended that I decided to create a game in software unsuitable for this task. But this gave me even more joy because I knew it would be a unique project.

After a couple of weeks of work after hours, this is what I created:

Walkthrough

The goal of the game is to defuse a bomb that is placed somewhere inside the box. The player can move the camera and explore the object by clicking on the cyan handles. A welcome menu is displayed at the beginning, where he can familiarize himself with this mechanic.

welcome_menu.webp

A welcome menu with control instructions.

By clicking on a button located on the other side of the menu, the mystery cube is shown to the player, and the countdown to the explosion begins.

game_begins.webp

The game begins.

The first is a "Tile-sliding game" inspired by a favorite game from my childhood - The Neverhood. The player is tasked with arranging tiles by clicking on them. Setting the correct combination opens a trapdoor with a hidden button. Clicking on this button changes its color.

sliding-puzzle.webp

The solved sliding-tiles puzzle unveils the hidden button.

sliding_puzzle-inspiration.webp

The inspiration from the Neverhood game.

The second puzzle is a "Pipe game". Here the player, by clicking on the pipes, can rotate them. By arranging them in a valid path and clicking a button, water begins to pour from the top "valve" and fill the pipes, which, when full, change color. Each output valve colors the pipes a different color.

pipes.webp

The pipes puzzle.

To unveil the third puzzle, follow the clue on the side of the cube. The player needs to set the color of the button to cyan and the color of the pipes to magenta. This combination opens a side door, hiding an "Electro board game".

electro-opened.webp

The electro board game lid opened.

Here I took inspiration from an old browser game, the name of which I can't remember. The idea was to move the cursor from the starting point to the finish line while holding the mouse button. Touching the black area with the cursor triggered a jump scare cut scene. In the case of my game, I skipped the jump scare and focused on making an interesting maze. To make things more complicated, the light turns off after completing half of the route, leaving the player in darkness illuminated only by rays emanating from a small sphere of energy following the cursor. After reaching the finish line, the light returns, and a LED light starts to blink on the electronic board.

electro-before.webp electro-during.webp
On the left - unsolved electro board puzzle; on the right - during transportation of the energy field.

electro-inspiration.webp

The inspiration for the electro-board puzzle.

Turning the cube 180 degrees around, another puzzle presents itself to the player's eyes - a keypad and morse code instructions. The player has to correctly find the element on the cube that sends an encrypted message, decrypt it and type it on the numeric keypad.

keypad.webp

The keypad inside the box.

The flashing part is the LED diode from the previous puzzle, which broadcasts code 7365. Typing it into the keypad opens the box door, where the prize in the form of a UV flashlight awaits the player.

keypad-solved.webp

The correct code opens the box with the UV flashlight.

Pressing the "U" key turns on the UV light, changing the colors visible on the cube. The player notices a hidden fingerprint on the bottom of the cube, invisible in standard lighting.

box-no_uv.webp box-with_uv.webp
On the left - box in the standard lighting conditions; on the right - UV lighting applied, the fingerprint is visible

Clicking on it opens a lid at the bottom of the box. Inside there are two clues. The first tells the player to click on a small button and adjust the color of the pipes and the sliding-tiles button.

bottom-clue1.webp

The first clue hidden under the box.

The second clue is a "Knights and Knaves" style problem in which a set of statements is provided, but some are true, and some are false. The puzzle's goal is to determine which statements are valid based on the information given.

bottom-clue2-no_uv.webp bottom-clue2-with_uv.webp.jpg
On the left - Truth-Tellers and Liars hint without UV light; on the right - with UV light.

What is the valuable information for? The player, while turning the cube, notices that the door to the bomb has been opened, and there are three cables of different colors. To stop the bomb, one must cut the blue cable - a color that solves the Knights and Knaves problem.

bomb.webp

The bomb and three cables.

Below you can see the whole gameplay in a single video:

Assets

Most 3D models and textures were created by me in Cinema 4D and textured in Substance Painter. As I was primarily concerned with creating unique mechanisms and logic, I abandoned the idea of using high-quality assets and stopped at a conceptual level.

assets.webp

Models I created for this project.

Development in Notch

As I stated in the introduction section, by any means, Notch is not a proper tool for creating heavy-logic-driven applications. One reason I think that way is because there is no idea like precomposing a group of nodes by collapsing them into a single custom one allowing for easy reuse of developed logic (like Prefabs in Unity). The node graph is linearly growing and growing until the editor becomes so slow that, at some point, inserting even one node is almost unfeasible.

nodegraph.webp

The actual monstrous node graph of this project.

The only reasonable solution is to move most of the logic into JavaScript, which, unfortunately, is an outdated version that does not implement many modern core features. Two of them, which would make my life much easier while developing this project, are class-based inheritance and import declarations.

While the first one can be replaced with prototypal inheritance, the latter causes a lot of code duplication and hinders a logic flow by forcing the usage of the node graph as a top-level script. To communicate between objects kept nicely in separate files, I used Envelope Modifier nodes (denoted with a prefix #) inside Notch as intermediate registers. It's like using global variables, but instead of declaring them in a script file, I declare them by spawning nodes and giving them unique names. Not handy at all.

1672211390-SXj0OhFgY2.webp

States of two different puzzles stored inside envelope nodes trigger the unveiling of another puzzle.

Object picking

What would be a point-and-click game without the capability of clicking and interacting with in-game objects? For sure, it would require much more imagination from players. Notch does not provide any node's method for detecting objects clicked by the user. But it gives us a Mouse Picker node which captures the mouse cursor's location in a screen-space coordinate and informs whether the left or right button has been clicked. How can we use this information to detect the related object?

My first idea for object picking was to use a color-picking technique, as implementing raycasting in Notch is not an option. I could use Object ID buffer by using GBuffer To Image node... but there is no way of dereferencing a node by the object's ID. Instead, I sampled from the World Position buffer to receive color=coordinates under the cursor.

3dcursor-worldspace.webp 3dcursor-diffuse.webp
Getting 3D coordinates by sampling from the world position buffer.

3d_cursor.webp

Sampling position of a cursor in 3D space from World Position buffer.

Next, I created a Null node and tested its position against Hot Zone nodes. In the image below, notice the red bounding box around a pipe. It's a sign that this zone detected a null inside its boundaries.

3dcursor-hotzone.webp

Testing cursor's null position against Hot Zones for each corresponding pipe.

This allowed me to relate interaction zones with proper meshes and develop logic around this solution with this information.

An example of testing whether a button is under the mouse cursor
var button = {
    ...
    IsHit: function() {
        var node = layer.FindNode("#sliding_puzzle_button_hotzone")
        return node.GetEnvelopeValue("Current Hit Zone") == 1 ? true : false;
    }
}

Mechanism of a keypad

I was conferring for some time about which mechanism I shall depict to approximate the general logic I developed in this project. In the end, I decided to describe a keypad as it's straightforward and implements similar solutions I used in other puzzles.

keypad-normal.webp keypad-boxes.webp
On the right - visible interaction boxes and red mouse click debug position.

Starting with describing its construction, let's look at the node graph. It consists of 3D models, materials, Hot Zone nodes and JS-linked envelope nodes designated by # prefix in their names.

keypad-nodegraph.webp{:height 402, :width 746}

The node graph of the keypad.

keypad-3d.webp

A raw 3D model of the keypad.

The Hot Zone node, in combination with the 3D cursor mechanism, detects if the user clicked on a button.

keypad-hitzone.webp

The Hot Zone node - the user clicked on the button "8".

The digital screen consists out of two Text String nodes. One for a typed code (#keypad_code_text) and one for a blinker (_). The #keypad_code_digits envelope controls an offset of the blinker and toggles its visibility.

keypad-nodegraph-screen.webp

The construction of a screen node graph.

Lastly, there is an LED on the right of the keypad. It's a simple 3D model with a material, which's color is controlled using JS.

keypad-nodegraph-led.webp

The LED node graph.

Before I dive into describing the JS script part, I would like to introduce its basic anatomy briefly. Three main methods are lying in the heart of each script - Init(), Update(), and OnKeyPress(key). The first one is invoked only once when the script is loaded after all nodes on the node graph are initialized. It's an excellent place to link references to essential nodes. The second one is called by Notch every frame - equivalent to Unreal Engine's Tick() method. And the last one is called when the editor is focused, and the key is pressed.

I divided the logic into seven distinct objects. Inside the Init() method, I initialize each of them by fetching appropriate envelope node references.

keypad.js - linking JS objects with nodes references
function Init() {
    var layer = UpdateContext.Layer

    mouse.Init(layer);
    game.Init(layer);
    puzzle_state.Init(layer);

    code.Init(layer);
    screen.Init(layer);
    led.Init(layer);
    buttons.Init(layer);
}

Next, in the Update() method I perform interaction and general update steps:

keypad.js - per frame tick
function Update(){
    InteractionStep();
    UpdateStep();
}

The InteractionStep() handles a high-level logic:

keypad.js - handling the user interaction and ongoing flow of logic
function InteractionStep() {
    if (mouse.IsClicked()) {
        hit = buttons.GetHit();
        if (hit != -1) {
            buttons.Press(hit);

            if (buttons.IsDigitHit(hit)) {
                code.AddDigit(hit)
            } else if (buttons.IsClearHit(hit)) {
                code.Reset();
            } else if (buttons.IsConfirmHit(hit)) {
                if (code.IsCorrect()) {
                    buttons.BlockInput();
                    screen.SetText("---OK---");
                    led.Inform(true);
                    puzzle_state.Set(1);
                    return
                } else { // Code incorrect
                    led.Inform(false);
                    code.Reset();
                }
            }

            screen.SetText(code.Get());
        }
    }
}

Lastly, inside UpdateStep() I detect whether the user requested a reset of the game and perform a reset of each object that implements a Reset() method:

keypad.js - monitoring whether the reset request has been broadcasted
function UpdateStep() {
    // Reset the script if reset has been requested in end game
    if (game.ResetRequested()) {
        puzzle_state.Reset();
        code.Reset();
        screen.Reset();
        led.Reset();
    }
}

How are these objects implemented? From top to bottom, the first is a mouse object, which handles user mouse input. It does not directly detect mouse click but rather read info from intermediate envelopes, which are modified by a mouse.js script.

keypad.js - getting information about the mouse interaction
var mouse = {
    Init: function(layer) {
        this.cursor = layer.FindNode("#cursor");
        if (!this.cursor)
            Log("ERROR[keypad.js/mouse.Init]: #cursor node could not be found");

        this.clicked_node = layer.FindNode("#mouse_clicked");
        if (!this.clicked_node)
            Log("ERROR[keypad.js/mouse.Init]: #mouse_clicked node could not be found");

        this.pressed_node = layer.FindNode("#mouse_pressed");
        if (!this.pressed_node)
            Log("ERROR[keypad.js/mouse.Init]: #mouse_clicked node could not be found");
    },

    BlockInput: function(unlock_after) {
        this.blocked = true;
    },

    prev_frame_clicked: false, // Depending on a framerate, sometimes the click is captured twice
    IsClicked: function() {
        if (this.blocked) return false;

        if (this.clicked_node.GetEnvelopeValue("Value") == 1) {
            return !this.prev_frame_clicked;
        } else if (this.prev_frame_clicked) {
            this.prev_frame_clicked = false;
        }

        return false;
    },

    IsPressed: function() {
        if (this.blocked) return false;

        return this.pressed_node.GetEnvelopeValue("Value") == 1 ? true : false;
    }
};

The second game object detects if a reset of the game has been requested. After unsuccessful bomb defusal, a big button pops up, allowing the player to click it and try again.

keypad.js - the listener to game reset request
var game = {
    Init: function(layer) {
        this.reset_request_node = layer.FindNode("#reset_requested");
        if (!this.reset_request_node)
            Log("ERROR[keypad.js/game.Init]: #reset_requested node not found!");
    },

    ResetRequested: function() {
        return this.reset_request_node.GetEnvelopeValue('Value') == 1 ? true : false;
    }
}

The puzzle_state object outputs whether the user solved the puzzle. In this case, value 1 opens a box's lid behind the keypad.

keypad.js - puzzle output state manager
var puzzle_state = {
    Init: function(layer) {
        this.output = layer.FindNode("#keypad_output_state");
        if (!this.output)
            Log("ERROR[keypad.js/puzzle_state.Init]: #keypad_output_state node could not be found!");

        this.Reset();
    },

    Reset: function() {
        this.Set(-1);
    },

    Set: function(value) {
        this.output.SetEnvelopeValue("Value", value);
    }
}

1672221243-Q2fGidNwBa.webp

The JS envelope is connected to the opening mechanism of the box.

Next, the code object manages entered code. The correct code is hardcoded as it refers to the morse code emitted by LED, which is stored as a video file.

keypad.js - managing the entered code code
var code = {
    entered: "",
    correct_code: "7365",

    Init: function(layer) {
        this.Reset();
    },

    Reset: function() {
        this.entered = "";
    },

    AddDigit: function(digit) {
        if (this.entered.length >= this.correct_code.length)
            return;

        this.entered += digit;
    },

    Get: function() {
        return this.entered;
    },

    IsCorrect: function() {
        return this.entered == this.correct_code;
    }
}

The screen object is connected to Text String nodes and manages the displayed text:

keypad.js - keypad screen text controller
var screen = {
    Init: function(layer) {
        this.code_text = layer.FindNode("#keypad_code_text");
        if (!this.code_text)
            Log("ERROR[keypad.js/screen.Init]: #keypad_code_text node could not be found!");

        this.code_digits_count = layer.FindNode("#keypad_code_digits_count");
        if (!this.code_digits_count)
            Log("ERROR[keypad.js/screen.Init]: #keypad_code_digits_count node could not be found!");

        this.Reset();
    },

    Reset: function() {
        this.SetText("");
    },

    SetText: function(string) {
        this.code_text.SetString("Attributes.Text String", string);
        this.code_digits_count.SetEnvelopeValue("Value", string.length);
    }
}

The led object is a controller for the LED light next to the keypad. Its blink capability owes to the in-built Timer method. Thank you, Notch, for implementing this fundamental feature : )

keypad.js - LED light controller
var led = {
    default_color: COLOR.BLUE,
    color: COLOR.BLUE,
    is_on: true,

    Init: function(layer) {
        this.color_r = layer.FindNode("#keypad_led_r");
        this.color_g = layer.FindNode("#keypad_led_g");
        this.color_b = layer.FindNode("#keypad_led_b");

        if (!this.color_r || !this.color_g || !this.color_b)
            Log("ERROR[keypad.js/led.Init]: LED color nodes could not be found!");

        this.Reset();
    },

    Reset: function() {
        this.SetColor(this.default_color);
        this.Toggle(1);
    },

    SetColor: function(color) {
        this.color = color;
    },

    Blink: function(duration_seconds) {
        if (!this.IsOn()) {
            this.Toggle(1);

            Timer(duration_seconds, function() { led.Toggle(0) }, name="", false);
        } else {
            this.Toggle(0);

            Timer(duration_seconds, function() { led.Toggle(1) }, name="", false);
        }
    },

    IsOn: function() {
        return this.is_on;
    },

    Toggle: function(on) {
        this.is_on = on;

        this.color_r.SetEnvelopeValue('Value', this.color[0] * (on == true ? 1 : 0));
        this.color_g.SetEnvelopeValue('Value', this.color[1] * (on == true ? 1 : 0));
        this.color_b.SetEnvelopeValue('Value', this.color[2] * (on == true ? 1 : 0));
    },

    Inform: function(correct) {
        this.SetColor(correct == true ? COLOR.GREEN : COLOR.RED);

        this.Blink(0.3); // Blink once now
        Timer(0.7, function() { led.Blink(0.3) }, name="", false); // Blink once again after 0.4 second
        if (!correct) {
            Timer(1.4, function() { led.Blink(0.3) }, name="", false); // Blink once again after 0.4 second
            Timer(2.1, function() { led.Reset() }, name="", false); // Reset color after 0.4 second
        }
    }
}

And the last, the buttons object detects whether the user clicked on a button and sends feedback information to nodes controlling 3D models Z position:

keypad.js - keypad buttons controller
var buttons = {
    blocked: false,

    Init: function(layer) {
        this.animation = [];
        this.hotzone = [];

        var animation_node;
        var hotzone_node;
        for (var i = 0; i < 12; i++) {
            if (i < 10) {
                animation_node = layer.FindNode("#keypad_button_animation_" + i)
                if (!animation_node)
                    Log("ERROR[keypad.js/buttons.Init]: Keypad button " + i + " animation node could not be found!");
                else
                    this.animation.push(animation_node);

                hotzone_node = layer.FindNode("#keypad_button_hotzone_" + i)
                if (!hotzone_node)
                    Log("ERROR[keypad.js/buttons.Init]: Keypad button " + i + " hotzone node could not be found!");
                else
                    this.hotzone.push(hotzone_node);
            } else if (i == 10) { // CLEAR button
                animation_node = layer.FindNode("#keypad_button_animation_x")
                if (!animation_node)
                    Log("ERROR[keypad.js/buttons.Init]: Keypad CLEAR button animation node could not be found!");
                else
                    this.animation.push(animation_node);

                hotzone_node = layer.FindNode("#keypad_button_hotzone_x")
                if (!hotzone_node)
                    Log("ERROR[keypad.js/buttons.Init]: Keypad CLEAR button hotzone node could not be found!");
                else
                    this.hotzone.push(hotzone_node);
            } else if (i == 11) { // OK button
                animation_node = layer.FindNode("#keypad_button_animation_ok")
                if (!animation_node)
                    Log("ERROR[keypad.js/buttons.Init]: Keypad OK button animation node could not be found!");
                else
                    this.animation.push(animation_node);

                hotzone_node = layer.FindNode("#keypad_button_hotzone_ok")
                if (!hotzone_node)
                    Log("ERROR[keypad.js/buttons.Init]: Keypad OK button hotzone node could not be found!");
                else
                    this.hotzone.push(hotzone_node);
            }
        }
    },

    BlockInput: function() {
        this.blocked = true;
    },

    GetHit: function() {
        if (this.blocked) return -1;

        for (i = 0; i < this.hotzone.length; i++) {
            var is_hit = this.hotzone[i].GetEnvelopeValue("Current Hit Zone") == 1 ? true : false;

            if (is_hit) {
                return i;
            }
        }

        return -1;
    },

    Press: function(which_button) {
        var current_value = this.animation[which_button].GetEnvelopeValue('Value');
        this.animation[which_button].SetEnvelopeValue('Value', (current_value + 1) % 2);
    },

    IsDigitHit: function(which_button) { return which_button < 10; },
    IsClearHit: function(which_button) { return which_button == 10; },
    IsConfirmHit: function(which_button) { return which_button == 11; }
}

Conclusions

Every time I write a publication for my blog, I remind myself of the fantastic things I have created and the valuable skills I possess. After the Notchmeister2 contest ended, I claimed my prize and archived this project on a hard drive deep inside my desk. I never tried to show and explain all the magic that happened inside.

golden_dongle.webp

The contest prize - a "golden" Notch dongle.

This blog made me open it again, and I am thankful for it. Giving my code a subtle touch of refactoring after such a long time was a gratifying experience, as I had an opportunity to don my old perspective and notice how much I learned over the past years.

One thing I would write differently would be using prototypal inheritance and creating more OOP structure in combination with programming patterns, e.g., a command pattern for handling button click actions.

Links and files

Full Notch .dfx project (requires at least PLE Notch license) >>> LINK <<<

GitHub repository with JS scripts: >>> LINK <<<