Building the Feedback Form

We’re about a year out from Cantata’s initial release, and though the game wasn’t received like we had hoped, I’m still damn proud of what we created from both a technical and design standpoint. So I want to take the time (and with a bit of distance) to finally dive into the technical parts of the game that I’m proud of and shine a light on all the hard work that happened under the surface. Which is to say, welcome to the first of the hopefully-weekly installment of “Building Cantata”.

The Feeback Form

Cantata, from the very beginning, was always something of a discussion between our own internal designs and how that was received by the community, especially during the EA period. So to start, I thought it could be good to talk about the Feedback Form. For context, it looks like this:

In any campaign match or skirmish in Cantata, you can press F8 and it will bring up a feedback window.

As a player (or QA…!) you can use this to send feedback to us. Feedback can take multiple forms (But or General Feedback), and we provide players the ability to self-report a “vibe” of the feedback as well.

When a user fills this out, a few things happen. Actually, a few things already have. When the feedback form button is clicked, but before the panel is displayed, a screenshot of the game is taken and stored in memory as raw byte data. So when you’re filling out the form we’ve already got a screenshot (though you can retake the screenshot in the button as well, and if you click this you’ll note that we quickly close and reopen the feedback window so it doesn’t block the view of the game).

Some Background

I’ll take a small moment here to also recognize some “prior work” in this space. Patient 0 of this, as far as I know, was the feedback form in Subnautica. They spoke about in a GDC talk here (nearly a decade ago!).

The devs in that video make a really compelling case for that type of system, but I was surprised to not really see this become the norm across all games. It’s cropped up a few times since, but still largely seems to be the exception rather than the rule.

BenUI has a great break down of the lineage of the tooling here, as well as a walkthrough about their (great!) implementation in Industries of Titan. For a really deep overview of the possibilities of such a system, I definitely recommend reading this:

Ben’s post goes a lot into best practices, but specifically omits technical details. Rightly so, as the specifics can quickly diverge based on lots of different factors (as you’ll see below), but I’ll go into detail about how we do it here.

Data Construction

The form itself is just built using the standard Unity UGUI system, but the form itself gathers data from those input fields and binds it to an internal data structure (standard UI stuff):

protected override void OnUIEnabled()   {
  //MAIN FORM
  SetReportTypeBug.Bind(() => SetCurrentFormType(FeedbackType.Bug));
  SetReportTypeFeedback.Bind(() => SetCurrentFormType(FeedbackType.Feedback));
  FeedbackTextInput.BindEditEvents((value) => FeedbackTextUpdated(value));
  FeedbackTextInput.onValueChanged.AddListener((value) => FeedbackTextUpdated(value));

  AngryVibe.Bind(() => SetCurrentFormVibe(FeedbackVibe.Angry));
  SadVibe.Bind(() => SetCurrentFormVibe(FeedbackVibe.Sad));
  WhateverVibe.Bind(() => SetCurrentFormVibe(FeedbackVibe.Whatever));
  HappyVibe.Bind(() => SetCurrentFormVibe(FeedbackVibe.Happy));
  LoveVibe.Bind(() => SetCurrentFormVibe(FeedbackVibe.Love));

  RetakeScreenshotButton.Bind(() => RetakeScreenshot());
  SendFormButton.Bind(() => SendCurrentForm());

  //RECIEVED
  FeedbackRecievedPanel.gameObject.SetActive(false);
  SubmitMore.Bind(() => {FeedbackRecievedPanel.gameObject.SetActive(false);UpdateForm();});
  CloseFeedbackRecievedPanel.Bind(() => gameObject.SetActive(false));

  //SENDING
  SendingFeedbackPanel.gameObject.SetActive(false);

  //FAILED
  FeedbackFailedToSendPanel.gameObject.SetActive(false);
  FeedbackFailedBackButton.Bind(() => FeedbackFailedToSendPanel.gameObject.SetActive(false));

  UpdateForm();
  SetupTooltips();
}

I’ll probably be talking about UI a lot over the course of this series, so I’ll go ahead and point out a pattern here for Unity UI that I rigorously adhered to: all UI is bound in code.

This was huge for us because a lot of Cantata’s UI is dynamic and built at runtime, so we couldn’t really rely on inspector-centric binding. Additionally, we get really clear visibility (via references in the IDE) about who/what cares about a given UI element vs. doing hunting in the inspector.

So here, you can see that happening - everything is bound in OnEnable (and unbound later).

The form itself is just a plain C# class with one primary function, which is to effectively “serialize” (kind of!) itself into WWWFormData, aka the www/form-data data type we use to submit the actual request:

public Dictionary<string,string> AsWWWFormData()
{
    var cameraCenterTile = MatchManager.MatchCamera.Utils.GetCenterTile();
    int camX,camY;
    if(cameraCenterTile != null)
    {
        camX = cameraCenterTile.X;
        camY = cameraCenterTile.Y;
    }
    else
    {
        camX = -1;
        camY = camX;
    }
    //NOTE: DO NOT CHANGE THE NAME OF THESE KEYS
    var data = new Dictionary<string, string>(){
        {"name",FeedbackText},
        {"version",GameManager.Instance.BuildInfo.ToString()},
        {"type",FormType.ToString()},
        {"vibe",FormVibe.ToString()},

        {"cpu", SystemInfo.processorType + " ("+SystemInfo.processorCount+" logical processors)"},
        {"gpu", SystemInfo.graphicsDeviceName + " ("+SystemInfo.graphicsMemorySize+"MB)"},
        {"ram", SystemInfo.systemMemorySize + ""},
        {"os", SystemInfo.operatingSystem},

        {"PlayerLevel",MatchManager.LastFocusedLocalPlayer.Level.ToString()},
        {"CurrentRound",MatchManager.RoundNumber.ToString()},
        {"TotalPlayerInteractables",MatchManager.LastFocusedLocalPlayer.Interactables.Count.ToString()},
        {"TotalPlayerNonStorageInteractables",MatchManager.LastFocusedLocalPlayer.Interactables.Where(x => !x.OffMap).ToList().Count.ToString()},
        
        {"CenterTileX",camX.ToString()},
        {"CenterTileY",camY.ToString()}
    };

    var s = "";
    foreach (var kvp in data)
    {
        s += $"{kvp.Key}: {kvp.Value}\n";
    }
    data.Add("description",s);

    return data;
}

(I’ll also looking at some code for the first time in years so don’t judge me, I see a few ways this could be better!)

This basically just takes the data and puts it into the form-data format so the server can parse it as such. As a side note, I used form-data here because I also needed to submit a screenshot with the data, and didn’t want to go through too much hassle of trying to put that into plain text.

The screenshot code itself is also surprisingly simple:

IEnumerator TakeScreenshot()   {
    FeedbackContainer.gameObject.SetActive(false);
    yield return new WaitForEndOfFrame();
    int width = Screen.width;
    int height = Screen.height;
    Texture2D tex = new Texture2D(width, height, TextureFormat.RGB24, false);
    tex.ReadPixels(new Rect(0, 0, width, height), 0, 0);
    tex.Apply();
    //This basically takes a screenshot
    // byte[] bytes = tex.EncodeToPNG(); //Can also encode to jpg, just make sure to change the file extensions down below
    byte[] bytes = tex.EncodeToJPG(); //Can also encode to jpg, just make sure to change the file extensions down below
    Destroy(tex);
    OnScreenshotTaken(bytes);
}

Sending the form

And actually sending the form:

void SendCurrentForm()
{
    var formData = CurrentForm.AsWWWFormData();

    WebRequest req = null;
    var url = "omitted"; //screenshot url

    var tempLog = "No Log Sent";
    byte[] logData = System.Text.Encoding.UTF8.GetBytes(tempLog);
    
    if(IncludeScreenshotToggle.isOn)
    {
        req = Util.CreateFormEncodedPOSTRequestWithScreenshot(
            url : url,
            formData : formData,
            screenshotData : screenshotData,
            logData : logData
        );
    }
    else
    {
        url = "omitted";
        req = Util.CreateFormEncodedPOSTRequest(
            url : url,
            formData : formData,
            logData : logData
        );
    }

req.OnSuccess += (request) => {
    Clog.L("request successful!");
    var res = JsonUtility.FromJson<FormattedResponse>(request.downloadHandler.text);

    SendingFeedbackPanel.SetActive(false);
    FeedbackRecievedPanel.SetActive(true);
    ReportIDPanel.text = res.ticket.ToString();

    sendingRequest = false;
    CurrentForm = null;
    hasScreenshot = false;
};

    req.OnFail += (request) => {
        Clog.L("request failed!");
        sendingRequest = false;
        SendingFeedbackPanel.SetActive(false);
        FeedbackFailedToSendPanel.SetActive(true);
    };

    SendingFeedbackPanel.SetActive(true);
    sendingRequest = true;
    SendingText.text = "Sending..";
    StartCoroutine(AnimateSendingText());
    req.Send();
}

The real load-bearing stuff here is the call to this function:

public static WebRequest CreateFormEncodedPOSTRequestWithScreenshot(string url, Dictionary<string,string> formData, byte[] screenshotData, byte[] logData, bool send = false, Dictionary<string,string> headers = null)
{
    var r = GameObject.Instantiate(GameManager.Instance.WebRequest, Vector3.zero, Quaternion.identity).GetComponent<WebRequest>();
    r.Setup(url,formData,screenshotData,logData,send,headers);
    return r;
}

This is a static function that basically instantiates the WebRequest prefab object and sets it up:

public void Setup(string url, Dictionary<string,string> formData, byte[] screenshotData, byte[] logData, bool sendImmediate = false, Dictionary<string,string> headers = null)
{
    //https://docs.unity3d.com/Manual/UnityWebRequest-SendingForm.html
    //https://support.gamesparks.net/support/discussions/topics/1000050374
    //https://docs.unity3d.com/ScriptReference/Networking.UnityWebRequest.Post.html

    //we dont use the following api but useful reference if we switch to the new version
    //https://answers.unity.com/questions/1354080/unitywebrequestpost-and-multipartform-data-not-for.html
    URL = url;
    Form = new WWWForm();
    foreach (var item in formData)
    {
        Form.AddField(item.Key,item.Value);
    }
    Form.AddBinaryData("playerlog", logData, "player.log", "text/plain");
    Form.AddBinaryData("screenshot", screenshotData, "screenshot.jpg", "image/jpg");

    Request = UnityWebRequest.Post(URL, Form);
    if(headers != null)
    {
        foreach (var header in headers)
        {
            Request.SetRequestHeader(header.Key, header.Value);
        }
    }
    requestSetup = true;
    if(sendImmediate)
    {
        Send();
    }
}

And then the actual send:

Why is this an object?

Well, I wanted the lifecycle of the request to be self-contained and also use Coroutines, but not have them be on the form itself. TBH I could see refactoring this to just use a plain C# object and some functions of the form. The request itself handles all the request setup and dispatch, functionally wrapping UnityWebRequest in a higher level to have a nicer entry API.

Receiving the form on the server

This is setup to send the form data to a cloud function I hosted on Pipedream. You could really use whichever cloud function service you want here (Zapier, Firebase, etc.), but for whatever reason at the time I chose Pipedream (I think having programming as a first class thing instead of pre-composed “blocks”).

In Pipedream, the main function was to receive the submitted form data, then transform it into an API call into our bug tracking database Linear.

You can see this here:

This effectively parses the form-data and then matches it with preset ids and such that correspond to resources on the Linear API side.

So for example, if someone submits “Angry” for the vibe, we convert that the the “angry” label in Linear, identified by the label’s id 675a4f52-eb4f-4f33-8496-cb17ea806bc8.

It’s worth noting here that we use Linear through Pipedream instead of submitting directly from the client so we don’t end up exposing our Linear API key to users that download Cantata. Using a hosted solution allows us to bake the key into Pipedream.

One thing worth pointing out here that’s really nice about Pipedream is that it automatically will upload your screenshot data to a public, temporary storage location that exposes a public URL. So even though I submit binary data for the screenshot, I get “magic” access to a hosted url:

description += "![screenshot](" + steps.cantataGameFeedbackForm.event.body.screenshot.url + ")";

After the data is transformed into a Linear-friendly data blob, we can use that newly formed data object here:

After that, we listen for the response from Linear, and are able to send back the created Issue ID to the client. This was an idea I got from Ben/Industries of Titan, as it allows players to follow up on Discord with a note about their issue. So if they copy the issue down, they can follow up in Discord with more context/urgency/etc.

From there, the bug is logged into our bug tracker!

Other Linear things

But there’s more!

Linear specifically has a really great feature called “Triage” that takes API created bugs and puts them in a specific folder so they dont disappear into the rest of the backlog. We can quickly then view all feedback form bugs in a specific place in the bug tracker. Here’s what an issue looks like after it’s created:

Note how the vibe and feedback type become actual tags. Pretty nice! You can also see how we display all the actual system information in the ticket, as well as the user-submitted information.

Back on the Unity side, we receive the success and update the text with the ticket ID:

req.OnSuccess += (request) => {
    Clog.L("request successful!");
    var res = JsonUtility.FromJson<FormattedResponse>(request.downloadHandler.text);

    SendingFeedbackPanel.SetActive(false);
    FeedbackRecievedPanel.SetActive(true);
    ReportIDPanel.text = res.ticket.ToString();

    sendingRequest = false;
    CurrentForm = null;
    hasScreenshot = false;
};

And with that we’ve completed the loop! Also! With the above, you can basically do this yourself for any game! People charge $40 for this normally, and you could pretty easily just set it up in a day!

Conclusion

The feedback form was a really lovely thing to get into the game, all things considered. It was also so good that the QA team also ended up using it instead of needing to log bugs elsewhere. The engagement was high, and also surprisingly brought out a lot larger “community” than would have been experienced on Discord. People used it to submit bugs, but also as a way to sort of “talk to us” without needing to join the Discord to have a full-blown conversation.

To anyone else that submitted bugs or feedback through the feedback form, thank you as well! Some of you diligently logged bugs and feedback in the form but never posted on Discord, and are some of the most unsung heroes of the game. Thank you!

And with that I’m closing out the first, long-delayed post on Building Cantata! Looking forward to whatever I end up writing about next week, but regardless was nice to get reacquainted with some old code like an old friend :slight_smile:

See you next time!