Friday Night Funkin' Mods
About the Game
My experience working on the Vs. Ace mod is very similar to working on the Vs. Retrospecter mod, since it was with a similar team. The process of developing Vs. Retrospecter was longer and had more lessons that I learned, so this page will talk about my experience with that mod only.
Friday Night Funkin': Vs. Retrospecter is a mod created from the game Friday Night Funkin' where the main character, Boyfriend, faces off against a new foe named Retrospecter. Boyfriend and Girlfriend venture into the depths of hell, but are stopped at the Gates of Wrath by Retrospecter, who is meant to represent the sin of wrath. He will only allow Boyfriend and Girlfriend to pass if Boyfriend proves his worth by singing, though being the sin of wrath, Retro may not allow them to pass so easily.
Developed from May 2021 to August 2021.
Play the game here!
Role in Development
- Manager: Retrospecter
- Artists: Andrew J., Blue, BonesTheSkelebunny01, Crudaka, Dax, D6, Iago, Jade, Kevin B., KittenSneeze, Pyxl, Ravi, Razur, RobynTheDragon, Shiba Chichi, Springy_4264, Tenzu, Wildface, WolfWrathKnight
- Animators: Andrew J., KittenSneeze, Speck, Springy_4264, Tenzu, Wildface, WolfWrathKnight
- Designer: David Liu, Retrospecter
- Programmers: AyeTSG, Carbon, David Liu
Friday Night Funkin': Vs. Retrospecter was developed in the 2D HaxeFlixel engine using the Kade Engine framework. I lead the team on the programming side, managing tasks and delegating them between me and Carbon (AyeTSG helped with just sprite implementation and small edits). Every day throughout the summer, I worked closely with Retrospecter with brainstorming ideas, discussing possibilities with artists, and organizing the project's tasks and milestones.
The app we use for communication is Discord, so it turned into the platform we used to keep track of tasks and manage the project as well. Task management apps like Trello didn't stick to the team, since not everyone knew how to use it and it was concluded that it wasn't worth putting the time into. Instead, we did our best to make things work by organizing discussions into different channels and keeping a list of tasks for each area in development.
The development process was very strenuous, as we didn't have a concrete art pipeline since each artist used different art programs. Some people had Adobe Animate while others didn't, because they couldn't afford it. The other art programs used are FireAlpaca, ToonBoom, and Clip Studio. HaxeFlixel also loads in spritesheets with using a XML file as a key. Only Adobe Animate (as far as we knew) could export with XML sheets, so we had to use a tool or create them manually otherwise.
As for the programming end, most of my struggles near the start of development was learning the engine. I jumped into the project without any experience using HaxeFlixel before, so it took me around a week to fully understand how it all works. The XML keys with spritesheets was also new to me, but I quickly adapted and picked up how they worked and how they are loaded into the engine. The rest went fairly smooth, where I picked up the process of editing XML files from artists that didn't use Adobe Animate, or used a tool to put spritesheets together. I modified the Kade Engine framework to fit the needs of the project, and fixed any bugs that were prominent.
The final stretch of the project was what felt like the most intense part. We distributed the build of the game to a group of playtesters before releasing publicly, and that's where the loads of bugs and issues came in. While the bugs related to flaws in systems or logic inn the code was easy to fix, the harder ones to figure out was issues that we eventually found out was caused by an excessive amount of memory being used. This was rather hard to fix, since we did our best to optimize the code as much as we could in the given amount of time (without having to rewrite everything), but then we found some excess space in the spritesheets. Cutting out the white space took out a decent chuck of memory usage and fixed the issue for the playtesters. However, the issue still remained for players after release on PCs with lower memory.
After release, the reputation for the mod was phenomenal. Lots of Youtubers and streamers recorded themselves playing the game and most of them absolutely loved it. For the most part, there were very little issues as well, though for the small amount we did spot from looking at the streams, we then got to fixing immediately. Within the first day of release, we pushed out a hot fix for bugs and adjustments to gameplay. In the end, I'm very proud of the team and the hard work that was put into the mod. I learned how to take on the role of managing a side of the project and helping organize everything up until the mod was shipped, and quickly patching any issues from player feedback. I've had my first taste of finally getting a project out to a large audience, and I can't wait to take on more projects and create even grander experiences for people.
Haxe Examples
OptionsMenu.hxCreditsState.hxpackage; import Options; import flixel.FlxG; import flixel.FlxSprite; import flixel.group.FlxGroup.FlxTypedGroup; import flixel.input.gamepad.FlxGamepad; import flixel.text.FlxText; import flixel.tweens.FlxEase; import flixel.tweens.FlxTween; import flixel.util.FlxColor; class OptionsMenu extends MusicBeatState { public static var instance:OptionsMenu; var curSelected:Int = 0; var options:Array= [ new OptionCategory("Gameplay", [ new DFJKOption(controls), new DownscrollOption("Toggle making the notes scroll down rather than up."), new CustomStrumLineOption("Toggle using the new colored strum lines or the default ones."), new GhostTapOption("Toggle counting pressing a directional input when no arrow is there as a miss."), new Judgement("Customize your Hit Timings. (LEFT or RIGHT)"), #if desktop new FPSCapOption("Change your FPS Cap."), #end new ScrollSpeedOption("Change your scroll speed. (1 = Chart dependent)"), new AccuracyDOption("Change how accuracy is calculated. (Accurate = Simple, Complex = Milisecond Based)"), new ResetButtonOption("Toggle pressing R to gameover."), new InstantRespawn("Toggle if you instantly respawn after dying."), new CustomizeGameplay("Drag and drop gameplay modules to your prefered positions!") ]), new OptionCategory("Appearance", [ new BackgroundOption("Change how much of the background is rendered to increase performance."), new EditorRes("Not showing the editor grid will greatly increase editor performance"), new CamZoomOption("Toggle the camera zoom in-game."), new StepManiaOption("Sets the colors of the arrows depending on quantization instead of direction."), new AccuracyOption("Display accuracy information on the info bar."), new SongPositionOption("Show the song's current position as a scrolling bar."), new NPSDisplayOption("Shows your current Notes Per Second on the info bar."), new RainbowFPSOption("Make the FPS Counter flicker through rainbow colors."), new CpuStrums("Toggle the CPU's strumline lighting up when it hits a note."), ]), new OptionCategory("Visual Effects",[ new ModChartsOption("Allow HUD effects for songs created by Lua scripting."), new FlashingLightsOption("Toggle flashing lights that can cause epileptic seizures and strain."), new MotionOption("Toggle images and objects with fast motion to reduce motion sickness."), new ChromaticAberrationOption("Toggle glitchy visual effect that can cause epileptic seizures and strain."), new GhostTrailsOption("Toggle trails left by sprites for intense visuals."), new ParticlesOption("Toggle particle creation that can slow down performance."), new ScreenShakeOption("Toggle shaking the screen from impactful motions."), new WindowShakeOption("Toggle shaking the window from super impactful motions."), ]), new OptionCategory("Misc", [ new FPSOption("Toggle the FPS Counter"), new FlashingLightsOption("Toggle flashing lights that can cause epileptic seizures and strain."), new WatermarkOption("Enable and disable all watermarks from the engine."), new AntialiasingOption("Toggle antialiasing, improving graphics quality at a slight performance penalty."), new MissSoundsOption("Toggle miss sounds playing when you don't hit a note."), new ScoreScreen("Show the score screen after the end of a song"), new ShowInput("Display every single input on the score screen."), new Optimization("No characters or backgrounds. Just a usual rhythm game layout."), new GraphicLoading("On startup, cache every character. Significantly decrease load times. (TONS OF MEMORY)"), new CutsceneLoading("On startup, cache every cutscene. Significantly decrease load times. (HIGH MEMORY)"), new BotPlay("Showcase your charts and mods with autoplay.") ]), new OptionCategory("Saves and Data", [ #if desktop new ReplayOption("View saved song replays."), #end new ResetScoreOption("Reset your score on all songs and weeks. This is irreversible!"), new LockWeeksOption("Reset your story mode progress. This is irreversible!"), new ResetSettings("Reset ALL your settings. This is irreversible!") ]) ]; public var acceptInput:Bool = true; private var currentDescription:String = ""; private var grpControls:FlxTypedGroup ; public static var versionShit:FlxText; var currentSelectedCat:OptionCategory; var blackBorder:FlxSprite; override function create() { instance = this; var menuBG:FlxSprite = new FlxSprite().loadGraphic(Paths.image("menuDesat")); menuBG.color = 0xFFea71fd; menuBG.setGraphicSize(Std.int(menuBG.width * 1.1)); menuBG.updateHitbox(); menuBG.screenCenter(); menuBG.antialiasing = FlxG.save.data.antialiasing; add(menuBG); grpControls = new FlxTypedGroup (); add(grpControls); for (i in 0...options.length) { var controlLabel:Alphabet = new Alphabet(0, (70 * i) + 30, options[i].getName(), true, false, true); controlLabel.isMenuItem = true; controlLabel.targetY = i; grpControls.add(controlLabel); // DONT PUT X IN THE FIRST PARAMETER OF new ALPHABET() !! } currentDescription = "none"; versionShit = new FlxText(5, FlxG.height + 40, 0, "Offset (Left, Right, Shift for slow): " + HelperFunctions.truncateFloat(FlxG.save.data.offset,2) + " - Description - " + currentDescription, 12); versionShit.scrollFactor.set(); versionShit.setFormat("VCR OSD Mono", 16, FlxColor.WHITE, LEFT, FlxTextBorderStyle.OUTLINE, FlxColor.BLACK); blackBorder = new FlxSprite(-30,FlxG.height + 40).makeGraphic((Std.int(versionShit.width + 900)),Std.int(versionShit.height + 600),FlxColor.BLACK); blackBorder.alpha = 0.5; add(blackBorder); add(versionShit); FlxTween.tween(versionShit,{y: FlxG.height - 18},2,{ease: FlxEase.elasticInOut}); FlxTween.tween(blackBorder,{y: FlxG.height - 18},2, {ease: FlxEase.elasticInOut}); changeSelection(); super.create(); } var isCat:Bool = false; override function update(elapsed:Float) { super.update(elapsed); if (acceptInput) { if (controls.BACK && !isCat) FlxG.switchState(new MainMenuState()); else if (controls.BACK) { isCat = false; grpControls.clear(); for (i in 0...options.length) { var controlLabel:Alphabet = new Alphabet(0, (70 * i) + 30, options[i].getName(), true, false); controlLabel.isMenuItem = true; controlLabel.targetY = i; grpControls.add(controlLabel); // DONT PUT X IN THE FIRST PARAMETER OF new ALPHABET() !! } curSelected = 0; changeSelection(curSelected); } if (controls.UP_P) changeSelection(-1); if (controls.DOWN_P) changeSelection(1); if (isCat) { if (currentSelectedCat.getOptions()[curSelected].getAccept()) { if (FlxG.keys.pressed.SHIFT) { if (FlxG.keys.pressed.RIGHT) currentSelectedCat.getOptions()[curSelected].right(); if (FlxG.keys.pressed.LEFT) currentSelectedCat.getOptions()[curSelected].left(); } else { if (FlxG.keys.justPressed.RIGHT) currentSelectedCat.getOptions()[curSelected].right(); if (FlxG.keys.justPressed.LEFT) currentSelectedCat.getOptions()[curSelected].left(); } } else { if (FlxG.keys.pressed.SHIFT) { if (FlxG.keys.justPressed.RIGHT) FlxG.save.data.offset += 0.1; else if (FlxG.keys.justPressed.LEFT) FlxG.save.data.offset -= 0.1; } else if (FlxG.keys.pressed.RIGHT) FlxG.save.data.offset += 0.1; else if (FlxG.keys.pressed.LEFT) FlxG.save.data.offset -= 0.1; versionShit.text = "Offset (Left, Right, Shift for slow): " + HelperFunctions.truncateFloat(FlxG.save.data.offset,2) + " - Description - " + currentDescription; } if (currentSelectedCat.getOptions()[curSelected].getAccept()) versionShit.text = currentSelectedCat.getOptions()[curSelected].getValue() + " - Description - " + currentDescription; else versionShit.text = "Offset (Left, Right, Shift for slow): " + HelperFunctions.truncateFloat(FlxG.save.data.offset,2) + " - Description - " + currentDescription; } else { if (FlxG.keys.pressed.SHIFT) { if (FlxG.keys.justPressed.RIGHT) FlxG.save.data.offset += 0.1; else if (FlxG.keys.justPressed.LEFT) FlxG.save.data.offset -= 0.1; } else if (FlxG.keys.pressed.RIGHT) FlxG.save.data.offset += 0.1; else if (FlxG.keys.pressed.LEFT) FlxG.save.data.offset -= 0.1; versionShit.text = "Offset (Left, Right, Shift for slow): " + HelperFunctions.truncateFloat(FlxG.save.data.offset,2) + " - Description - " + currentDescription; } if (controls.RESET) FlxG.save.data.offset = 0; if (controls.ACCEPT) { if (isCat) { if (currentSelectedCat.getOptions()[curSelected].press()) { grpControls.members[curSelected].reType(currentSelectedCat.getOptions()[curSelected].getDisplay()); } } else { currentSelectedCat = options[curSelected]; isCat = true; grpControls.clear(); for (i in 0...currentSelectedCat.getOptions().length) { var controlLabel:Alphabet = new Alphabet(0, (70 * i) + 30, currentSelectedCat.getOptions()[i].getDisplay(), true, false); controlLabel.isMenuItem = true; controlLabel.targetY = i; grpControls.add(controlLabel); // DONT PUT X IN THE FIRST PARAMETER OF new ALPHABET() !! } curSelected = 0; } changeSelection(); } } FlxG.save.flush(); } function changeSelection(change:Int = 0) { #if !switch // NGio.logEvent("Fresh"); #end FlxG.sound.play(Paths.sound("scrollMenu")); curSelected += change; if (curSelected < 0) curSelected = grpControls.length - 1; if (curSelected >= grpControls.length) curSelected = 0; if (isCat) currentDescription = currentSelectedCat.getOptions()[curSelected].getDescription(); else currentDescription = "Please select a category"; if (isCat) { if (currentSelectedCat.getOptions()[curSelected].getAccept()) versionShit.text = currentSelectedCat.getOptions()[curSelected].getValue() + " - Description - " + currentDescription; else versionShit.text = "Offset (Left, Right, Shift for slow): " + HelperFunctions.truncateFloat(FlxG.save.data.offset,2) + " - Description - " + currentDescription; } else versionShit.text = "Offset (Left, Right, Shift for slow): " + HelperFunctions.truncateFloat(FlxG.save.data.offset,2) + " - Description - " + currentDescription; // selector.y = (70 * curSelected) + 30; var bullShit:Int = 0; for (item in grpControls.members) { item.targetY = bullShit - curSelected; bullShit++; item.alpha = 0.6; // item.setGraphicSize(Std.int(item.width * 0.8)); if (item.targetY == 0) { item.alpha = 1; // item.setGraphicSize(Std.int(item.width)); } } } }