Creating interactive game level selection menu

ui tutorials

11/1/2024

Martin Bozhilov

Learn how to create interactive game level selection menu cards. Hovering over any card triggers a video preview of that level, providing players with a dynamic and immersive glimpse of the environment before they dive in.

Overview

In this tutorial, we’ll guide you through implementing a game-level selection menu with interactive videos, allowing them to play as animations when players interact with them. We will use Unreal’s built-in Bink Video plugin for seamless video integration with the Live Views feature of Gameface. This setup ensures smooth playback directly from Unreal Engine, creating a visually engaging experience for your players.

Resources

The videos for this UI were taken from Freepik .

List of videos:

Getting started - Unreal Engine

We’ll begin by setting up the project in Unreal Engine. Use the getting started guide from our documentation to install the Gameface plugin and integrate it into an existing project or use the sample project created by the installer.

Once that’s complete, create a new blank map in Unreal Engine to start configuring your setup.

Setting up the Bink videos

First, we need to convert the video files we wish to use into Bink format. You can follow the official guide in the UE documentation for step-by-step instructions on setting up your videos.

Important: Make sure to place your videos in the Content/Movies directory within your Unreal Engine project folder. Unreal Engine will not recognize them if they’re stored elsewhere.

Creating the Render Targets

Next up, for the live view to work we need to create a TextureRenderTarget2D texture for each of our videos and save it in an accessible location within the project files. In our case, we made a separate folder called Render Targets placed in the Blueprints folder and placed the Render Targets there.

After that we have to set the dimension of each Render Target to match the resolution of our videos. In our case that is 1280x720. To set this, double-click on the Render Target file and locate the dimension settings on the right panel.

If you are just getting started with Unreal Engine or the Live views feature, you can get more aquainted with it by checking out our other Unreal tutorials utilizing the power of Live Views or check out our documentation to read more about them.

Creating the HUD class

To define a custom event with parameters, we need to create a C++ class that extends the CohtmlGameHUD class provided by the Gameface plugin. This custom C++ class will then be used as the HUD class type for our Blueprint HUD class.

We will follow the UI Scripting with C++ guide to define the custom event and make it accessible in our Blueprints. The event will take a single argument — the index of each video as we loop through them.

Start by creating the header file as follows:

MyCohtmlGameHUD.h
1
#pragma once
2
3
#include "CoreMinimal.h"
4
#include "CohtmlGameHUD.h"
5
#include "MyCohtmlGameHUD.generated.h"
6
7
UCLASS()
8
class AMyCohtmlGameHUD : public ACohtmlGameHUD
9
{
10
GENERATED_BODY()
11
12
public:
13
virtual void BeginPlay() override;
14
UFUNCTION(BlueprintImplementableEvent, BlueprintCallable, Category = Gameplay)
15
void TogglePlay(const int index);
16
private:
17
UFUNCTION()
18
void BindUI();
19
};

In this header file, we define the handler that will execute when the JavaScript event TogglePlay is triggered.

Now, let’s implement this in the C++ source file:

MyCohtmlGameHUD.cpp
1
#include "MyCohtmlGameHUD.h"
2
#include <CohtmlHUD.h>
3
4
void AMyCohtmlGameHUD::BeginPlay()
5
{
6
Super::BeginPlay();
7
GetCohtmlHUD()->ReadyForBindings.AddDynamic(this, &AMyCohtmlGameHUD::BindUI);
8
}
9
void AMyCohtmlGameHUD::BindUI()
10
{
11
GetCohtmlHUD()->GetView()->RegisterForEvent("TogglePlay", cohtml::MakeHandler(this, &AMyCohtmlGameHUD::TogglePlay));
12
}

In the BindUI method, we register the TogglePlay event, which will be triggered from the frontend whenever we want to control video playback.

Once you’ve created and compiled this class, you can set up your HUD Blueprint class.

Assign this newly created HUD class as the active HUD in your map’s World Settings. This setup enables the HTML to display as the UI for your game.

Setting up the view and the input

If you have already integrated Gameface with Unreal Engine you can skip this section Create a Setup Input method in the previously created BP to enable user input for the UI:

To initialize our HTML page for the UI, setup the view and set the input to the view in the blueprint you created.

Implementing the blueprint logic

To implement the logic for our animated menu UI, we’ll need to create three variables in our Blueprint:

  • Bink Media Players Array: An array of Bink Media Player items that will hold the video files.
  • Render Targets Array: An array of Texture Render Target items to hold the render textures for each video.
  • Active Video Booleans Array: An array of boolean values, which we’ll use to control which video is currently active. Each boolean will indicate whether a video at a specific index is playing.

After creating these three array variables with the appropriate types, compile the Blueprint to initialize them. Populate each array with the relevant elements:

  • Bink Media Players Array: Add the Bink videos you want to display. We named our variable BinkVideos
  • Render Targets Array: Add the render targets for each video. We named our variable Textures
  • Active Video Booleans Array: Set each boolean value to false by default, so no video is active at the start. We named our variable hoveredState

Important: Ensure that the indexes of each video in the Bink Media Players array correspond to the same indexes in the Render Targets array. This way, each video will correctly match its respective render target, allowing us to control them accurately within the Blueprint logic.

With that out of the way, let’s start implementing the core logic of our UI.

Implementing video draw logic

As explained in our documentation on Bink integration , we need to call the Draw method on each Bink video instance every tick to display them in the UI.

Since we have an array of 5 videos we are going to achieve that with the help of a For Each Loop node. The loop will iterate through all videos, drawing each one onto the corresponding Render Texture. By matching the index of each video in the Bink Media Players array with the index in the Render Targets array, we ensure each video displays on its correct texture.

The blueprint setup will look like this:

Implementing our custom event

To meet our goal of playing each video only when it’s interacted with, we’ll use the custom event we defined earlier in C++. You can add this event in the Blueprint by right-clicking in the editor and searching for the name we assigned it — Toggle Play in this case.

The logic is straightforward. We’ll use the Toggle Play event to toggle the values in our boolean array. In the Event Tick, we’ll check which index in the boolean array is set to true, indicating the active video. Only the video at that index will play, while others will pause.

We’ll send the appropriate video index as a parameter to the event from the frontend, allowing us to target specific videos. In the Blueprint, the setup looks like this:

Putting it all together

With our custom event in place, we can now enhance our logic by iterating over the Booleans array to determine the active video. For each boolean in the array:

  • If the value is true, we play the corresponding video.
  • If the value is false, we pause the video.

Additionally, we can call the seek method to reset the video when switching states, ensuring each video starts from the beginning upon activation. As the seek method expects to be passed a time, we can just drag the node and click promote to variable and leave it like that as it defaults to 00:00.

The complete setup, with all the necessary nodes added, will look like this:

With that the Unreal Engine implementation is done and we can proceed with setting up our UI.

Getting started - Frontend

We’ll begin by creating an index.html file in the uiresources folder.

Import cohtml.js

To enable communication between the UI and the game, the cohtml.js library must first be imported.

index.html
1
<body>
2
<script src="./cohtml.js"></script>
3
</body>

Setting up the layout

The layout for this UI will be quite simple. We will add a background-image on the body and an element with class of background-filter to apply a filter on the body image. Additionally, we will define a wrapper to wrap our content and a card-wrapper element to hold our interactive menu card elements.

index.html
1
<body>
2
<div class="background-filter"></div>
3
<div class="wrapper">
4
<h1>Select A Location</h1>
5
<div class="card-wrapper">
6
</div>
7
</div>
8
</body>

And the styles:

styles.css
6 collapsed lines
1
@font-face {
2
font-family: 'Inknut Antiqua';
3
src: url('./font/InknutAntiqua-SemiBold.ttf');
4
font-weight: normal;
5
font-style: normal;
6
}
7
8
body {
9
margin: 0;
10
width: 100vw;
11
height: 100vh;
12
position: relative;
13
display: flex;
14
align-items: center;
15
justify-content: center;
16
flex-direction: column;
17
font-family: "Inknut Antiqua", serif;
18
background-image: url('./assets/background.png');
19
background-size: cover;
20
background-position: center;
21
background-repeat: no-repeat;
22
}
23
24
.background-filter {
25
width: 100%;
26
height: 100%;
27
background-color: black;
28
opacity: 0.75;
29
position: absolute;
30
top: 0;
31
left: 0;
32
z-index: 1;
33
}
34
35
h1 {
36
color: white;
37
text-shadow: 1px 1px 5px black;
38
font-size: 2.5vmax;
39
margin-top: 1vh;
40
margin-bottom: 0;
41
}
42
43
.wrapper {
44
width: 90%;
45
height: 90%;
46
display: flex;
47
flex-direction: column;
48
align-items:center;
49
position: relative;
50
z-index: 2;
51
}
52
53
.card-wrapper {
54
display: flex;
55
justify-content: space-between;
56
align-items: center;
57
width: 100%;
58
}

Adding the animated card styles and structure

The html structure of our cards will be the following:

index.html
1
<div class="card-wrapper">
2
<div class="animated-card skyline-passage">
3
<div class="card-content-wrapper">
4
<img class="video" src="/Script/Engine.TextureRenderTarget2D'/Game/Blueprints/RenderTargets/skylinePassage_RT.skylinePassage_RT'">
5
<div class="card-content">
6
<div class="card-content-heading">Skyline Passage</div>
7
<div class="card-content-progression">44%</div>
8
</div>
9
</div>
10
</div>
41 collapsed lines
11
12
<div class="animated-card meadows">
13
<div class="card-content-wrapper">
14
<img class="video" src="/Script/Engine.TextureRenderTarget2D'/Game/Blueprints/RenderTargets/verdantMeadows_RT.verdantMeadows_RT'">
15
<div class="card-content">
16
<div class="card-content-heading">Verdant Meadows</div>
17
<div class="card-content-progression">65%</div>
18
</div>
19
</div>
20
</div>
21
22
<div class="animated-card twilight-groove">
23
<div class="card-content-wrapper">
24
<img class="video" src="/Script/Engine.TextureRenderTarget2D'/Game/Blueprints/RenderTargets/twilightGroove_RT.twilightGroove_RT'">
25
<div class="card-content">
26
<div class="card-content-heading">The Twilight Grove</div>
27
<div class="card-content-progression">20%</div>
28
</div>
29
</div>
30
</div>
31
32
<div class="animated-card silent-shrines">
33
<div class="card-content-wrapper">
34
<img class="video" src="/Script/Engine.TextureRenderTarget2D'/Game/Blueprints/RenderTargets/silentShrines_RT.silentShrines_RT'">
35
<div class="card-content">
36
<div class="card-content-heading">The Silent Shrines</div>
37
<div class="card-content-progression">58%</div>
38
</div>
39
</div>
40
</div>
41
42
<div class="animated-card neon-horizon">
43
<div class="card-content-wrapper">
44
<img class="video" src="/Script/Engine.TextureRenderTarget2D'/Game/Blueprints/RenderTargets/NeonHorizon_RT.NeonHorizon_RT'">
45
<div class="card-content">
46
<div class="card-content-heading"> Neon Horizon</div>
47
<div class="card-content-progression">99%</div>
48
</div>
49
</div>
50
</div>
51
</div>

Connecting the videos to the HTML

Connecting the Bink videos to display in our HTML is pretty straightforward, we simply need to copy the Render Target reference from Unreal and add it as the source of our <image> elements:

index.html
1
<img class="video" src="/Script/Engine.TextureRenderTarget2D'/Game/Blueprints/RenderTargets/skylinePassage_RT.skylinePassage_RT'">

Creating the sliced border effect

To achieve the sliced border effect, we’ll use the CSS mask-image property.

For this trick to work, we are going to need 2 images:

  • A border image with a transparent center.
  • A solid color-filled version of the element.
Video Border Video Fill

We’ll apply the border image as a mask on a ::before pseudo-element attached to the main .animated-card element. This pseudo-element will be slightly larger than the card contents, creating the appearance of an actual border around the card.

styles.css
9 collapsed lines
1
.animated-card {
2
width: 18%;
3
height: 70%;
4
position: relative;
5
overflow: visible;
6
cursor: pointer;
7
transition: transform 0.2s ease-in-out;
8
filter: grayscale(100%); // Graying out the inactive cards
9
}
10
11
.animated-card::before {
12
content: '';
13
width: 102%;
14
height: 102%;
15
background-color: white;
16
mask-image: url('./assets/video-border.png');
17
mask-size: 100% 100%;
18
position: absolute;
19
left: -1%;
20
top: -1%;
21
z-index: 3;
22
}

Note: The background-color on the ::before pseudo-element determines the visible border color. The actual colors in the mask image are irrelevant, as mask-image only considers solid and transparent areas to define what is shown.

Currenly, our elements should look like this:

To complete the design, we need to add the fill image to the inner section of the video wrapper, which contains the video and its accompanying information. This inner section is the card-content-wrapper element.

Add the following CSS:

styles.css
1
.card-content-wrapper {
2
mask-image: url('./assets/video-fill.png');
3
mask-size: 100% 100%;
4
overflow: hidden;
5
}

Applying this mask to the card-content-wrapper will hide the top corners of the inner content, allowing the pseudo-element (with the border effect) to be visible around the edges. This creates the final polished look we want!

You can find the whole CSS source file in the final section.

Trigger hover event from the UI

With the design finalized, the last step is to trigger the Toggle Play event we defined in our Blueprints, sending it the correct video index.

Start by creating an index.js file and include it in the HTML’s <body> section.

In index.js, we’ll loop through all the video elements and attach two event listeners: mouseover and mouseleave. These will trigger the play/pause functionality when a user hovers over or leaves a menu item.

index.js
1
const videos = document.querySelectorAll('.animated-card')
2
3
videos.forEach((video, index) => {
4
video.addEventListener('mouseover', (event) => {
5
engine.trigger(`TogglePlay`, index)
6
})
7
8
video.addEventListener('mouseleave', (event) => {
9
engine.trigger(`TogglePlay`, index)
10
})
11
})

Important: Ensure that the videos in your HTML are in the same order as in the Bink videos array variable defined in your Blueprints. This alignment ensures that each index matches correctly, so the intended video plays or pauses as expected.

With this the UI is completed!

Full source code

HTML

index.html
1
<!DOCTYPE html>
2
<html lang="en">
3
<head>
4
<meta charset="UTF-8">
5
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6
<link rel="stylesheet" href="styles.css">
7
<title>Document</title>
8
</head>
9
<body>
10
<div class="background-filter"></div>
11
<div class="wrapper">
12
<h1>Select A Location</h1>
13
<div class="card-wrapper">
14
<div class="animated-card skyline-passage">
15
<div class="card-content-wrapper">
16
<img class="video" src="/Script/Engine.TextureRenderTarget2D'/Game/Blueprints/RenderTargets/skylinePassage_RT.skylinePassage_RT'">
17
<div class="card-content">
18
<div class="card-content-heading">Skyline Passage</div>
19
<div class="card-content-progression">44%</div>
20
</div>
21
</div>
22
</div>
47 collapsed lines
23
24
<div class="animated-card meadows">
25
<div class="card-content-wrapper">
26
<img class="video" src="/Script/Engine.TextureRenderTarget2D'/Game/Blueprints/RenderTargets/verdantMeadows_RT.verdantMeadows_RT'">
27
<div class="card-content">
28
<div class="card-content-heading">Verdant Meadows</div>
29
<div class="card-content-progression">65%</div>
30
</div>
31
</div>
32
</div>
33
34
<div class="animated-card twilight-groove">
35
<div class="card-content-wrapper">
36
<img class="video" src="/Script/Engine.TextureRenderTarget2D'/Game/Blueprints/RenderTargets/twilightGroove_RT.twilightGroove_RT'">
37
<div class="card-content">
38
<div class="card-content-heading">The Twilight Grove</div>
39
<div class="card-content-progression">20%</div>
40
</div>
41
</div>
42
</div>
43
44
<div class="animated-card silent-shrines">
45
<div class="card-content-wrapper">
46
<img class="video" src="/Script/Engine.TextureRenderTarget2D'/Game/Blueprints/RenderTargets/silentShrines_RT.silentShrines_RT'">
47
<div class="card-content">
48
<div class="card-content-heading">The Silent Shrines</div>
49
<div class="card-content-progression">58%</div>
50
</div>
51
</div>
52
</div>
53
54
<div class="animated-card neon-horizon">
55
<div class="card-content-wrapper">
56
<img class="video" src="/Script/Engine.TextureRenderTarget2D'/Game/Blueprints/RenderTargets/NeonHorizon_RT.NeonHorizon_RT'">
57
<div class="card-content">
58
<div class="card-content-heading"> Neon Horizon</div>
59
<div class="card-content-progression">99%</div>
60
</div>
61
</div>
62
</div>
63
</div>
64
</div>
65
66
<script src="cohtml.js"></script>
67
<script src="index.js"></script>
68
</body>
69
</html>

CSS

styles.css
1
@font-face {
2
font-family: 'Inknut Antiqua';
3
src: url('./font/InknutAntiqua-SemiBold.ttf');
4
font-weight: normal;
5
font-style: normal;
6
}
7
8
body {
9
margin: 0;
10
width: 100vw;
11
height: 100vh;
12
position: relative;
13
display: flex;
14
align-items: center;
15
justify-content: center;
16
flex-direction: column;
17
font-family: "Inknut Antiqua", serif;
18
background-image: url('./assets/background.png');
19
background-size: cover;
20
background-position: center;
21
background-repeat: no-repeat;
22
}
132 collapsed lines
23
24
.background-filter {
25
width: 100%;
26
height: 100%;
27
background-color: black;
28
opacity: 0.75;
29
position: absolute;
30
top: 0;
31
left: 0;
32
z-index: 1;
33
}
34
35
h1 {
36
color: white;
37
text-shadow: 1px 1px 5px black;
38
font-size: 2.5vmax;
39
margin-top: 1vh;
40
margin-bottom: 0;
41
}
42
43
.wrapper {
44
width: 90%;
45
height: 90%;
46
display: flex;
47
flex-direction: column;
48
align-items:center;
49
position: relative;
50
z-index: 2;
51
}
52
53
.card-wrapper {
54
display: flex;
55
justify-content: space-between;
56
align-items: center;
57
width: 100%;
58
}
59
60
.animated-card {
61
width: 18%;
62
height: 70%;
63
position: relative;
64
overflow: visible;
65
cursor: pointer;
66
transition: transform 0.2s ease-in-out;
67
filter: grayscale(100%);
68
}
69
70
.animated-card:nth-child(2),
71
.animated-card:nth-child(4) {
72
margin-bottom: 7.5vh;
73
}
74
75
.animated-card:nth-child(3) {
76
margin-bottom: 15vh;
77
}
78
79
.animated-card::before {
80
content: '';
81
width: 102%;
82
height: 102%;
83
background-color: white;
84
mask-image: url('./assets/video-border.png');
85
mask-size: 100% 100%;
86
position: absolute;
87
left: -1%;
88
top: -1%;
89
z-index: 3;
90
}
91
92
.animated-card:hover {
93
filter: none;
94
transform: scale(1.02);
95
}
96
97
.animated-card:hover .video {
98
transform: scale(1.1);
99
}
100
101
.animated-card .video {
102
width: 100%;
103
height: 100%;
104
transition: transform 0.2s ease-in-out;
105
}
106
107
.card-content-wrapper {
108
mask-image: url('./assets/video-fill.png');
109
mask-size: 100% 100%;
110
overflow: hidden;
111
}
112
113
.card-content {
114
position: absolute;
115
width: 100%;
116
top: 0;
117
left: 0;
118
color: white;
119
}
120
121
.card-content-heading {
122
padding: 2vh 2vh 1vh;
123
text-align: center;
124
text-shadow: 1px 1px 5px black;
125
font-size: 1vmax;
126
margin-bottom: 0.5vh;
127
}
128
129
.card-content-progression {
130
text-align: center;
131
font-size: 1.2vmax;
132
text-shadow: 1px 1px 10px black;
133
line-height: 2;
134
}
135
136
.skyline-passage .card-content-heading {
137
background-color: #312D7A44;
138
}
139
140
.meadows .card-content-heading {
141
background-color: #54700c6e;
142
}
143
144
.twilight-groove .card-content-heading {
145
background-color: #24025B6e;
146
}
147
148
.silent-shrines .card-content-heading {
149
background-color: #3a3c657d;
150
}
151
152
.neon-horizon .card-content-heading {
153
background-color: #230BA86e;
154
}

JavaScript

index.js
1
const videos = document.querySelectorAll('.animated-card')
2
3
videos.forEach((video, index) => {
4
video.addEventListener('mouseover', (event) => {
5
engine.trigger(`TogglePlay`, index)
6
})
7
8
video.addEventListener('mouseleave', (event) => {
9
engine.trigger(`TogglePlay`, index)
10
})
11
})

Image masks

Video Border
Video Border Fill

On this page