Sliding Puzzle OBS Streamer Bot Thing

  • 4 min read
  • Tags: 
  • OBS

Huh?

Sometimes a section of your stream interface is boring. Sometimes you need a way to raise your engagement. Sometimes you just allow the intrusive thoughts to take root and you want to make a bad idea barely work.

In all of these scenarios, the correct response is to implement The Sliding Puzzle OBS Streamer Bot Thing. Give your viewers a minigame to play made of whatever you’re trying to show them.

It technically works!

All of this was created 9 months or so before the blog post was written, so the details are a bit hazy.

The Solution

The plan is to combine Streamer.bot’s ability to run code on a trigger to send events to OBS through the built in websocket connections using their C# functionality.

First, the Split function is run. This function finds the relevant source and grabs a bunch of metadata, like transforms and locations and offsets. After it gets this information, it clones the source 8 times and applies some math to each of those transforms to turn each of them into a chunk of 1/9 of the source. During that creation, it sets a bunch of variables to keep track of where the empty space is, and which scene item is in each spot on the grid. Finally, it hides the original source because you probably don’t want to actually mess with the original.

The other part of the puzzle is the sliding actions. Each of these kind of do the same thing, but just manipulate the offsets in slightly different ways. First, check if the empty space is on an edge that would make the movement impossible (like sliding a piece up when the empty space is on the bottom row). Assuming the move is relevant, we fetch the scene item transforms for the piece that will be moving into the empty position, apply some math to the location, and push the new values to OBS then update the global variable state.

The global state should stay up to date through all of the moves as long as you don’t run split again, so you can just hide the child scene items and unhide the parent to get the input back to normal, and vice-versa to re-enable.

The Technical Bits

Global Variables

First, you need to figure out the sceneItemId for the thing that you want to turn into a sliding puzzle. The easy way to do this is to hook into OBS through the OBS Raw Streamer Bot page which takes in a scene name and returns all of the items. You can figure out the name of the relevant one by checking the sourceName values.

Grab that and head over to the global variables section and add all of these settings.

Global variables
slider_currentX = -1
slider_currentY = -1
slider_matrix00 = -1
slider_matrix01 = -1
slider_matrix02 = -1
slider_matrix10 = -1
slider_matrix11 = -1
slider_matrix12 = -1
slider_matrix20 = -1
slider_matrix21 = -1
slider_matrix22 = -1
slider_SceneName = [Your Scene Name]
slider_SourceSceneItemId = [the sceneItemId we got earlier]
slider_xCropDelta = -1
slider_yCropDelta = -1

Splits

Create an action to do the Split with this code implemented as a sub-action.

slider_SplitSubAction.cs
using System;
using Newtonsoft.Json.Linq;

public class CPHInline
{
    public bool Execute()
    {
        var sceneName = CPH.GetGlobalVar<string>("slider_SceneName", true);
        var sceneItemId = CPH.GetGlobalVar<string>("slider_SourceSceneItemId", true);
        //Duplicate, set global vars
        for (int i = 0; i < 8; i++)
        {
            var responseStr = CPH.ObsSendRaw("DuplicateSceneItem", $"{{\"sceneName\":\"{sceneName}\",\"sceneItemId\":{sceneItemId},\"destinationSceneName\":null}}", 0);
            CPH.LogDebug(responseStr.ToString());
            JObject resp = JObject.Parse(responseStr);
            var itemId = (int)resp["sceneItemId"];
            var matrixId = $"{(int)i / 3}{i % 3}";
            CPH.LogDebug($"Item Id: {itemId}, Matrix Id: {matrixId}");
            CPH.SetGlobalVar($"slider_matrix{matrixId}", itemId, true);
        }

        //Get some offsets
        var itemTransformResp = CPH.ObsSendRaw("GetSceneItemTransform", $"{{\"sceneName\":\"{sceneName}\",\"sceneItemId\":{sceneItemId}}}", 0);
        JObject originalTransform = JObject.Parse(itemTransformResp);
        var xOrigin = (int)originalTransform["sceneItemTransform"]["positionX"];
        var yOrigin = (int)originalTransform["sceneItemTransform"]["positionY"];
        var xCropDelta = (int)((int)(originalTransform["sceneItemTransform"]["sourceWidth"]) / 3);
        var yCropDelta = (int)((int)(originalTransform["sceneItemTransform"]["sourceHeight"]) / 3);
        CPH.LogDebug($"{xOrigin} - {yOrigin} - {xCropDelta} - {yCropDelta}");
        //Clean up each one
        for (int i = 0; i < 8; i++)
        {
            var matrixId = $"{(int)i / 3}{i % 3}";
            var tmpSceneItemId = CPH.GetGlobalVar<int>($"slider_matrix{matrixId}", true);
            //Let's get weird with JObject copying
            var newFancyTransform = JObject.FromObject(originalTransform).DeepClone().ToObject<JObject>();
            //Okay they send back 0 but require a minimum value of 1 even though you send back NO_BOUNDS ??? That took a hot minute to figure out
            newFancyTransform["sceneItemTransform"]["boundsWidth"] = 1;
            newFancyTransform["sceneItemTransform"]["boundsHeight"] = 1;
            //
            newFancyTransform.Add("sceneName", sceneName);
            //
            newFancyTransform["sceneItemId"] = tmpSceneItemId;
            newFancyTransform["sceneItemTransform"]["cropLeft"] = (int)newFancyTransform["sceneItemTransform"]["cropLeft"] + (((int)i / 3) * xCropDelta);
            newFancyTransform["sceneItemTransform"]["cropRight"] = (int)newFancyTransform["sceneItemTransform"]["cropRight"] + ((2 - ((int)i / 3)) * xCropDelta);
            newFancyTransform["sceneItemTransform"]["cropTop"] = (int)newFancyTransform["sceneItemTransform"]["cropTop"] + ((i % 3) * yCropDelta);
            newFancyTransform["sceneItemTransform"]["cropBottom"] = (int)newFancyTransform["sceneItemTransform"]["cropBottom"] + ((2 - (i % 3)) * yCropDelta);
            newFancyTransform["sceneItemTransform"]["positionX"] = (int)((int)newFancyTransform["sceneItemTransform"]["positionX"] + (((int)i / 3) * xCropDelta) * (double)newFancyTransform["sceneItemTransform"]["scaleX"]);
            newFancyTransform["sceneItemTransform"]["positionY"] = (int)((int)newFancyTransform["sceneItemTransform"]["positionY"] + ((i % 3) * yCropDelta) * (double)newFancyTransform["sceneItemTransform"]["scaleY"]);
            string tmpJson = newFancyTransform.ToString();
            CPH.ObsSendRaw("SetSceneItemTransform", tmpJson, 0);
        }
        //Make the original invisible
        CPH.ObsSendRaw("SetSceneItemEnabled", $"{{\"sceneName\":\"{sceneName}\",\"sceneItemId\":{sceneItemId},\"sceneItemEnabled\":false}}", 0);
        CPH.SetGlobalVar($"slider_currentX", 2, true);
        CPH.SetGlobalVar($"slider_currentY", 2, true);
        CPH.SetGlobalVar($"slider_xCropDelta", xCropDelta, true);
        CPH.SetGlobalVar($"slider_yCropDelta", yCropDelta, true);

        return true;
    }
}

Slides

Finally, create actions for the slides with the following sub-actions

slider_DownSubAction.cs
using System;
using Newtonsoft.Json.Linq;

public class CPHInline
{
    public bool Execute()
    {
        var sceneName = CPH.GetGlobalVar<string>("slider_SceneName", true);
        var currentX = CPH.GetGlobalVar<int>("slider_currentX", true);
        var currentY = CPH.GetGlobalVar<int>("slider_currentY", true);
        var xCropDelta = CPH.GetGlobalVar<int>("slider_xCropDelta", true);
        var yCropDelta = CPH.GetGlobalVar<int>("slider_yCropDelta", true);
        if (currentY == 0)
        {
            return true;
        }

        var sceneItemId = CPH.GetGlobalVar<int>($"slider_matrix{currentX}{currentY - 1}", true);
        CPH.LogDebug($"{sceneName} - {currentX} - {currentY} - {xCropDelta} - {yCropDelta} - {sceneItemId}");
        var itemTransformResp = CPH.ObsSendRaw("GetSceneItemTransform", $"{{\"sceneName\":\"{sceneName}\",\"sceneItemId\":{sceneItemId}}}", 0);
        JObject originalTransform = JObject.Parse(itemTransformResp);
        originalTransform["sceneItemTransform"]["positionY"] = (int)originalTransform["sceneItemTransform"]["positionY"] + (yCropDelta * (double)originalTransform["sceneItemTransform"]["scaleY"]);
        originalTransform["sceneItemTransform"]["boundsWidth"] = 1;
        originalTransform["sceneItemTransform"]["boundsHeight"] = 1;
        //
        originalTransform.Add("sceneName", sceneName);
        originalTransform.Add("sceneItemId", sceneItemId);

        var tmpJson = originalTransform.ToString();
        CPH.LogDebug(tmpJson);
		CPH.ObsSendRaw("SetSceneItemTransform", tmpJson, 0);
		//Write new state in global vars
		CPH.SetGlobalVar("slider_currentY", currentY - 1, true);
        CPH.SetGlobalVar($"slider_matrix{currentX}{currentY - 1}", -1, true);
        CPH.SetGlobalVar($"slider_matrix{currentX}{currentY}", sceneItemId, true);

        return true;
    }
}
slider_UpSubAction.cs
using System;
using Newtonsoft.Json.Linq;

public class CPHInline
{
    public bool Execute()
    {
        var sceneName = CPH.GetGlobalVar<string>("slider_SceneName", true);
        var currentX = CPH.GetGlobalVar<int>("slider_currentX", true);
        var currentY = CPH.GetGlobalVar<int>("slider_currentY", true);
        var xCropDelta = CPH.GetGlobalVar<int>("slider_xCropDelta", true);
        var yCropDelta = CPH.GetGlobalVar<int>("slider_yCropDelta", true);
        if (currentY == 2)
        {
            return true;
        }

        var sceneItemId = CPH.GetGlobalVar<int>($"slider_matrix{currentX}{currentY + 1}", true);
        CPH.LogDebug($"{sceneName} - {currentX} - {currentY} - {xCropDelta} - {yCropDelta} - {sceneItemId}");
        var itemTransformResp = CPH.ObsSendRaw("GetSceneItemTransform", $"{{\"sceneName\":\"{sceneName}\",\"sceneItemId\":{sceneItemId}}}", 0);
        JObject originalTransform = JObject.Parse(itemTransformResp);
        originalTransform["sceneItemTransform"]["positionY"] = (int)originalTransform["sceneItemTransform"]["positionY"] - (yCropDelta * (double)originalTransform["sceneItemTransform"]["scaleY"]);
        originalTransform["sceneItemTransform"]["boundsWidth"] = 1;
        originalTransform["sceneItemTransform"]["boundsHeight"] = 1;
        //
        originalTransform.Add("sceneName", sceneName);
        originalTransform.Add("sceneItemId", sceneItemId);

        var tmpJson = originalTransform.ToString();
        CPH.LogDebug(tmpJson);
		CPH.ObsSendRaw("SetSceneItemTransform", tmpJson, 0);
		//Write new state in global vars
		CPH.SetGlobalVar("slider_currentY", currentY + 1, true);
        CPH.SetGlobalVar($"slider_matrix{currentX}{currentY + 1}", -1, true);
        CPH.SetGlobalVar($"slider_matrix{currentX}{currentY}", sceneItemId, true);

        return true;
    }
}
slider_LeftSubAction.cs
using System;
using Newtonsoft.Json.Linq;

public class CPHInline
{
    public bool Execute()
    {
        var sceneName = CPH.GetGlobalVar<string>("slider_SceneName", true);
        var currentX = CPH.GetGlobalVar<int>("slider_currentX", true);
        var currentY = CPH.GetGlobalVar<int>("slider_currentY", true);
        var xCropDelta = CPH.GetGlobalVar<int>("slider_xCropDelta", true);
        var yCropDelta = CPH.GetGlobalVar<int>("slider_yCropDelta", true);
        if (currentX == 2)
        {
            return true;
        }

        var sceneItemId = CPH.GetGlobalVar<int>($"slider_matrix{currentX + 1}{currentY}", true);
        CPH.LogDebug($"{sceneName} - {currentX} - {currentY} - {xCropDelta} - {yCropDelta} - {sceneItemId}");
        var itemTransformResp = CPH.ObsSendRaw("GetSceneItemTransform", $"{{\"sceneName\":\"{sceneName}\",\"sceneItemId\":{sceneItemId}}}", 0);
        JObject originalTransform = JObject.Parse(itemTransformResp);
        originalTransform["sceneItemTransform"]["positionX"] = (int)originalTransform["sceneItemTransform"]["positionX"] - (xCropDelta * (double)originalTransform["sceneItemTransform"]["scaleX"]);
        originalTransform["sceneItemTransform"]["boundsWidth"] = 1;
        originalTransform["sceneItemTransform"]["boundsHeight"] = 1;
        //
        originalTransform.Add("sceneName", sceneName);
        originalTransform.Add("sceneItemId", sceneItemId);

        var tmpJson = originalTransform.ToString();
        CPH.LogDebug(tmpJson);
		CPH.ObsSendRaw("SetSceneItemTransform", tmpJson, 0);
		//Write new state in global vars
		CPH.SetGlobalVar("slider_currentX", currentX + 1, true);
        CPH.SetGlobalVar($"slider_matrix{currentX + 1}{currentY}", -1, true);
        CPH.SetGlobalVar($"slider_matrix{currentX}{currentY}", sceneItemId, true);

        return true;
    }
}
slider_RightSubAction.cs
using System;
using Newtonsoft.Json.Linq;

public class CPHInline
{
    public bool Execute()
    {
        var sceneName = CPH.GetGlobalVar<string>("slider_SceneName", true);
        var currentX = CPH.GetGlobalVar<int>("slider_currentX", true);
        var currentY = CPH.GetGlobalVar<int>("slider_currentY", true);
        var xCropDelta = CPH.GetGlobalVar<int>("slider_xCropDelta", true);
        var yCropDelta = CPH.GetGlobalVar<int>("slider_yCropDelta", true);
        if (currentX == 0)
        {
            return true;
        }

        var sceneItemId = CPH.GetGlobalVar<int>($"slider_matrix{currentX - 1}{currentY}", true);
        CPH.LogDebug($"{sceneName} - {currentX} - {currentY} - {xCropDelta} - {yCropDelta} - {sceneItemId}");
        var itemTransformResp = CPH.ObsSendRaw("GetSceneItemTransform", $"{{\"sceneName\":\"{sceneName}\",\"sceneItemId\":{sceneItemId}}}", 0);
        JObject originalTransform = JObject.Parse(itemTransformResp);
        originalTransform["sceneItemTransform"]["positionX"] = (int)originalTransform["sceneItemTransform"]["positionX"] + (xCropDelta * (double)originalTransform["sceneItemTransform"]["scaleX"]);
        originalTransform["sceneItemTransform"]["boundsWidth"] = 1;
        originalTransform["sceneItemTransform"]["boundsHeight"] = 1;
        //
        originalTransform.Add("sceneName", sceneName);
        originalTransform.Add("sceneItemId", sceneItemId);

        var tmpJson = originalTransform.ToString();
        CPH.LogDebug(tmpJson);
		CPH.ObsSendRaw("SetSceneItemTransform", tmpJson, 0);
		//Write new state in global vars
		CPH.SetGlobalVar("slider_currentX", currentX - 1, true);
        CPH.SetGlobalVar($"slider_matrix{currentX - 1}{currentY}", -1, true);
        CPH.SetGlobalVar($"slider_matrix{currentX}{currentY}", sceneItemId, true);

        return true;
    }
}

Reasonably Asked Questions

Why?

I have never worked with OBS or Streamer.bot before and this seemed like a good crash course.

Will this break if I resize parts of it, or the parent?

Yeah, almost assuredly. If you want to do that then run split again and reset it.

Are global variables the correct place to hold this information?

User persistent storage might work too? I am reasonably sure that it needs to be stateless so everything has to be written off somewhere.

Can this be made to have smooth animations?

Maybe! You can probably just update the location in the movement direction in a loop or something. If I try that then I can make a follow up post. Or you can do it and let me know.

All of the movement functions are essentially the same but with additions and subtractions moved around, surely that can be condensed?

If it can, let me know. The C# functionalty in Streamer.bot is pretty obtuse and I didn’t see a good way to make a reusable function to call from each slide method with parameters. I also did not look particularly hard.