Kara-Oke

Project Information

  1. Development Environment & Team
  2. Concept and Idea phase
  3. Sprint-to-Sprint progression
  4. Trailer
  5. Post Mortem

Game Mechanics

  1. PlayerSwitching.cs
  2. FadeSystem.cs
  3. Lyricsboard
  4. MusicSystem.cs
  5. SongInfo.cs

Project Information

As you might have seen in the brief description this game was developed for AVRO-TROS. A public broadcast corporation that wanted us to developed a prototype in the span of 8 weeks. The idea was to create a VR multiplayer Karaoke game in Unity within the format of a show called `Beste Zangers` With every project at XR-LAB 4 roles are given to members in each project: Product Owner, Lead Artist & Developer and Scrum Master. For this project i became the Lead developer ensuring code quality and managing git workflow Altough with alot of issues, i didnt do everything quite right but personally i think did a good enough job for the amount of stress we got but i will highlight any issues in the post mortem section We had a team of 5 developers (including me) and 4 artists, if you are interested in their contribution to the project you can look it up on their respective websites/artstations:

The concept was a Unity Multiplayer VR karaoke game within the format `Beste Zangers` Within the first sprint we planned alot and came up with tons of ideas for gameplay mechanics. Initially we wanted to just have Multiplayer and VR but then looking at the small market VR has. And an idea that came up during brainstorming was to include mobile players and have them interact with the game in some way We wanted to have some kind lobby system then have 1 VR player sing and have 4 players sit on the couch and interact with buttons or items, We wanted to take advantage of VR and do things that you normally couldnt do in real life karaoke. like throwing tomato's or roses at the person singing if you liked or hated their singing. Then we would have the mobile players who could join on their phone and also give a thumbs up or down to whoever is singing or use emojis Here you see a whiteboard where we brainstormed our ideas.
We then discussed more of the smaller details in the days after that and the artists on our team made alot of storyboards and concept art/sketches Here are some of those images:

Here i will briefly show and explain what we did in each sprint, We had 8 weeks and 4 sprint for 2 weeks.

Sprint 1

In sprint 1 we did mostly planning and brainstorming, our focus was to show our concept rather then a complete demo. We kind of split the team up in 2, Joao and William became our multiplayer research & development Duo and did 99% of the work on that front. The rest of the developers worked on smaller things like: Setting up Trello, Github, Empty Unity project, Unity Oculus integration, VR interaction and Frontend part of Mobile/Mobile UI Here are some screenshots of what we had to show:

Sprint 2

Sprint 2 involved more Multiplayer research and testing, This was the hardest part of the project, i will talk more about it in the post mortem section There was alot more art progression and we had first versions of some game mechanics: Lyricsboard, VR UI, Object Interaction, Effects I mostly worked on the lyrics board feature, more about this in the lyricsboard sections Here are some screenshots of what we had to show:

Sprint 3

In sprint 3 we decided to pull the plug for multiplayer as it was causing too many issues, we then decided to create NPC charachter to give a multiplayer feeling instead of actual multiplayer more about multiplayer problems in the post mortem section A very poorly written Main menu UI was created, Charachter selection and more Art progression. Oh and the lyricsboard system got reworked but you can read more about it in the game mechanics section Here are some screenshots of what we had to show:

Sprint 4

Sprint 4 we had to implement everything and finish everything, along with alot of documentation. with less and less motivation by the day we were able to put the entire project. we still had alot of bugs but fixed most of them 10 minutes before the last sprint review! hurray Here are some screenshots of what we had to show: Its not alot but to see the full game see the trailer below:

This project at the start was fun to work with but eventually everyone lost motivation and i have some gripes with it Multiplayer was a big issue because we didnt really have experience with it, Testing it together with VR is annoying since you ofcourse need 2 people and it just took along time to integrate multiplayer into our project without having the game mechanics work with multiplayer We also had super bad load times because of Oculus integration + Mirror (the multiplayer package we used). Alot of problems we encountered might have disapeared if we used Photon fusion instead of Mirror but who knows Another thing that could be a better aproach is instead of having an entire lyricsboard system in unity we could have also just put a video player component to the board model and just give the artists the work of putting a video together with the lyrics in some other software Another smaller but more annoying issue was code quality, as you see in the screenshot below there is 1 example of poorly written code that just isnt up to par with what is to be expected after 3 years of game dev experience This script was ofcourse re-written but still could be improved. As Lead dev of the project i should have paid more attention to it, and a crucial step that i overlooked: checking the current state of the game and tell what is needed. as there was alot of confusion about this We also only verbally agreed on some git workflow guidelines but have not written it somewhere so there was some confusion about it aswell. We also had alot of unnessicary disscussion about git workflow and game mechanics which were not clear which were annoying to say the least Overal we also had alot of bugs and things that should work but dont, poor team communication, oh and the entire repo was broken for 2 days

Game mechanics

In the game we wanted the player to switch from the couch position to the stage position, this is a small script i wrote to handle this You might see TeleportPosition Class, This is just a class that shows gizmos and nothing more. this can be swapped out for Transforms instead This script also interacts with the screenfade system, you can see more about it in the next section

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

using UnityEngine; using UnityEngine.Events; using Random = UnityEngine.Random; public sealed class PlayerSwitcher : MonoBehaviour { [SerializeField] private GameObject _playerObject; [SerializeField] private TeleportPosition _stagePosition; [SerializeField] private TeleportPosition[] _couchPositions; private bool _isOnstage = true; [SerializeField] private UnityEvent _onPlayerSwitch = new UnityEvent(); private void Awake() { _onPlayerSwitch.AddListener(SwitchTransition); } private void SwitchTransition() { ScreenFade.Instance.StartFade(this, nameof(SwitchPositions)); } private void SwitchPositions() { var targetPosition = _isOnstage ? GetRandomCouchPosition() : _stagePosition.transform; _playerObject.transform.position = targetPosition.position; _playerObject.transform.rotation = targetPosition.rotation; _isOnstage = !_isOnstage; } private Transform GetRandomCouchPosition() => _couchPositions[Random.Range(0, _couchPositions.Length)].transform; }

This was one of the smaller features that were not really necessary, I wanted to have a fade in/out effect when doing something but if i want to do that fade in/out in another script i didn't want everything to be hardcoded and cumbersome to work with So i found a way of doing it a somewhat better way using reflection, i also used method overloading to create the ability to call function with or without parameters. maybe this is too overengineered but it was done in 1-1.5 days so it was a big struggle

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

57

58

59

60

61

62

63

64

65

66

67

68

69

70

71

72

73

74

75

76

77

78

79

80

81

82

83

84

using System; using System.Collections; using System.Reflection; using UnityEngine; public sealed class ScreenFade : MonoBehaviour { public static ScreenFade Instance { get; private set; } [SerializeField] private Animator _animator; [SerializeField] private Canvas _canvas; private bool _isFading; private void Awake() { if (Instance != null && Instance != this) { Destroy(this); } else { Instance = this; DontDestroyOnLoad(gameObject); } Instance._canvas.worldCamera = Camera.main; } /// <summary> /// Starts a internal fade transition and allows for a method to be called within the fading transition /// </summary> /// <param name="originType">The type that holds the method that should be called</param> /// <param name="targetMethod">the string that represents the name of the method. use nameof() instead of ToString preferably</param> /// <typeparam name="T">T must be of type <b>class</b>. this can be done by using the <b>this</b> keyword</typeparam> public void StartFade<T>(T originType, string targetMethod) where T : class { if (!_isFading) StartCoroutine(Fade(originType, targetMethod)); } /// <summary> /// Starts a internal fade transition and allows for a method with parameters to be called within the fading transition /// </summary> /// <param name="originType">The type that holds the method that should be called</param> /// <param name="targetMethod">the string that represents the name of the method. use nameof() instead of ToString() preferably</param> /// <param name="args">the arguments that should be passed to the method</param> /// <typeparam name="T">T must be of type <b>class</b>. this can be done by using the <b>this</b> keyword</typeparam> public void StartFade<T>(T originType, string targetMethod, params object[] args) where T : class { if (!_isFading) StartCoroutine(Fade(originType, targetMethod, args)); } private IEnumerator Fade<T>(T type,string targetMethod, params object[] args) where T : class { if (_isFading) yield return null; yield return new WaitUntil(FadeIn); MethodInfo method = type.GetType().GetMethod(targetMethod, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance); method.Invoke(type,args); FadeOut(); _isFading = false; } private IEnumerator Fade<T>(T type,string targetMethod) where T : class { if (_isFading) yield return null; yield return new WaitUntil(FadeIn); _animator.Play("FadeIn"); var method = type.GetType().GetMethod(targetMethod, BindingFlags.NonPublic | BindingFlags.Public | BindingFlags.Instance); var action = (Action)Delegate.CreateDelegate(typeof(Action), type, method); action(); FadeOut(); _isFading = false; } private bool FadeIn() { _animator.Play("FadeIn"); return true; } private void FadeOut() { _animator.Play("FadeOut"); } }

This is the feature i worked on the most which could have probably replaced by a different approach on the problem we had. The lyrics board script had 3 version, The first was having the text scroll at a constant speed. The second version was just a bit different, also scrolling but being able to assign timestamp and change the speed on those timestamps The third and final version had 2 seperate text parts, and the ability to seperate parts of a song into different text parts each with their own speed and startingtimes using the timestamps

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

57

58

59

60

61

62

63

64

65

66

67

68

69

70

71

72

73

74

75

76

77

78

79

80

81

82

83

84

85

86

87

88

89

90

91

92

93

94

95

96

using System; using System.Collections; using TMPro; using UnityEngine; using UnityEngine.UI; public sealed class Lyricboard : MonoBehaviour { [SerializeField] private TextNode[] _texts; [SerializeField] private Transform _transformAnchor; [SerializeField] private Transform _startTransform; [SerializeField, Range(0.5f, 2f)] private float _tolerance; private int _tempoCount; private float _scrollSpeed; /// <summary> /// Calls the internal coroutine to start scrolling all text parts /// </summary> public void ScrollLyricNodes() { StartCoroutine(Scroll(MusicSelector.Instance.CurrentSong.Nodes)); } /// <summary> /// Resets the text transforms. the text itself and other internal values /// </summary> public void ResetScroll() { StopAllCoroutines(); var length = _texts.Length; for (var i = 0; i < length; i++) { _texts[i].Text.text = ""; _texts[i].Text.transform.position = _startTransform.position; _texts[i].IsScrolling = false; _texts[i].TextHeight = 0; } _tempoCount = 0; } private IEnumerator Scroll(SongInfo.LyricNode[] nodes) { if (nodes.Length == _tempoCount) yield break; TextNode targetText; if (_tempoCount == 0) targetText = _texts[0]; else targetText = _tempoCount % 2 == 0 ? _texts[0] : _texts[1]; if (targetText.IsScrolling) yield return new WaitUntil(() => !targetText.IsScrolling); targetText.Text.text = nodes[_tempoCount].TextPart; _scrollSpeed = nodes[_tempoCount].Speed; targetText.TextHeight = LayoutUtility.GetPreferredHeight(targetText.Text.rectTransform); yield return new WaitUntil(() => MusicSelector.Instance.TimestampIsValid(_tempoCount)); StartCoroutine(ScrollText(targetText)); _tempoCount++; StartCoroutine(Scroll(nodes)); } private IEnumerator ScrollText(TextNode targetText) { var tempSpeed = _scrollSpeed; targetText.IsScrolling = true; var anchorPosition = _transformAnchor.position; var targetPos = (anchorPosition.y + targetText.TextHeight) - _tolerance; while (true) { var pos = targetText.Text.transform.position; if (Mathf.Approximately(pos.y, targetPos)) break; float step = tempSpeed * Time.deltaTime; targetText.Text.transform.position = Vector3.MoveTowards(pos, new Vector3(pos.x, targetPos, pos.z), step); yield return new WaitForEndOfFrame(); } targetText.IsScrolling = false; targetText.Text.transform.position = _startTransform.position; } [Serializable] private sealed class TextNode { public TextMeshProUGUI Text; public bool IsScrolling { get; set; } public float TextHeight { get; set; } }

This script also uses a basic form of re-use by re-using 2 text parts. A better way would be to use object-pooling instead as currently you cannot have 3 text parts simultaneously on screen. This script also interacts with the musicsystem and SongInfo script

We have a MusicSystem script for playing music (duh). but it doesnt have alot of interaction with the lyricsboard script. the lyricsboard does use the TimestampValid function that checks if a textpart should play by comparing its own timestamp count with the audiosource time. Altough a weird bug would come up. Sometimes when there are long load times when switching scenes. the internal AudioSource.time value goes from 0 to 0.2 then instantly to 2 or 3. and because the lyricsboard system works in a recursive manner if the first comparison fails then the entire thing breaks. to fix this a quick fix was implemented by checking whether the source time is past the timestamp in addition to a normal equality comparison

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

57

58

59

60

61

62

63

64

65

66

67

68

69

70

71

72

73

74

75

76

77

78

79

80

81

82

83

84

85

86

87

88

89

90

91

92

93

94

95

96

97

98

99

100

101

102

103

104

105

106

107

108

109

110

111

using System; using UnityEngine; using UnityEngine.Events; using Random = UnityEngine.Random; public sealed class MusicSelector : MonoBehaviour { public static MusicSelector Instance { get; private set; } public float SongTime { get; private set; } public AudioSource Source; public SongInfo[] Songs; [SerializeField] private UnityEvent _SongStarted = new UnityEvent(); public SongInfo CurrentSong { get; private set; } private Lyricboard[] _lyricBoards; private void Awake() { if (Instance != null && Instance != this) Destroy(this); else Instance = this; _lyricBoards = FindObjectsOfType<Lyricboard>(); CurrentSong = Songs[0]; Source.clip = CurrentSong.Song; } private void Start() { StartSongNPC(); } public void StartSongNPC() { //SelectSong(5); SelectSong(Random.Range(7, Songs.Length)); StartSong(); } /// <summary> /// Starts the current song and starts all the lyricboards in the scene /// </summary> public void StartSong() { var length = _lyricBoards.Length; Source.Stop(); for (int i = 0; i < length; i++) { _lyricBoards[i].ResetScroll(); } if (WinSystem.Instance.NpcHasSung) { CurrentSong = SceneObject.Instance.SavedSceneData.SongChoice; } for (int i = 0; i < length; i++) { _lyricBoards[i].ScrollLyricNodes(); } Source.clip = CurrentSong.Song; SongTime = Source.clip.length; WinSystem.Instance.SetTimer(); Source.Play(); _SongStarted?.Invoke(); } /// <summary> /// Select which song to set as the current song /// </summary> /// <param name="index">The index of the Song info</param> public void SelectSong(int index) { var length = _lyricBoards.Length; if (Source.isPlaying) { Source.Stop(); for (int i = 0; i < length; i++) { _lyricBoards[i].ResetScroll(); } } CurrentSong = Songs[index]; Source.clip = CurrentSong.Song; } public void SelectSong(SongInfo targetSong) { CurrentSong = targetSong; Source.clip = CurrentSong.Song; } /// <summary> /// Check if a node is ready to be scrolled by comparing the source time and the node's timestamp /// </summary> /// <param name="index">which node to access in the current song</param> /// <returns></returns> public bool TimestampIsValid(int index) { if (Source.time > CurrentSong.Nodes[index].TimeStamp) return true; return Math.Abs(Source.time - CurrentSong.Nodes[index].TimeStamp) < 0.15f; } }

This script is a scriptable object and is used by the lyricsboard system to get a song and its info. It has a audioclip it needs to play and contains multiple text parts that each has text, a timestamp and a speed it should scroll at At the time of reading this i noticed i could also change each variable from private set to just const. The properties are all read-only but not immutable

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

using System; using UnityEngine; [CreateAssetMenu(fileName = "ScriptableObject / Song info")] public sealed class SongInfo : ScriptableObject { [field: SerializeField, Tooltip("This is the MP3 file that should play")] public AudioClip Song { get; private set; } [field: SerializeField, Tooltip("The person who made the song")] public string Artist { get; private set; } [Serializable] public struct LyricNode { [field: SerializeField, TextArea(5, 50)] public string TextPart { get; private set; } [field: SerializeField, Tooltip("The delay till the next lyric node")] public float TimeStamp { get; private set; } [field: SerializeField, Range(0.05f, 1f)] public float Speed { get; private set; } } [field: SerializeField] public LyricNode[] Nodes { get; private set; } }