The gameplay system manages the rhythm game mechanics, musical timing, scoring, and player input handling during song playback.
Core Gameplay Loop
The gameplay state (PlayState.hx) orchestrates all rhythm gaming systems:
PlayState Initialization
class PlayState extends MusicBeatSubState
{
public var currentSong:Song;
public var currentDifficulty:String = Constants.DEFAULT_DIFFICULTY;
public var currentVariation:String = Constants.DEFAULT_VARIATION;
public var currentStage:Null<Stage> = null;
public var health:Float = Constants.HEALTH_STARTING;
public var songScore:Int = 0;
public var startTimestamp:Float = 0.0;
public var playbackRate:Float = 1.0;
}
Key State Variables:
The currently playing song instance with metadata and chart data
Player’s current health value (starts at Constants.HEALTH_STARTING)
Player’s accumulated score for the current song
Song playback speed multiplier (1.0 = normal speed)
PlayState Parameters
When creating a PlayState instance, use PlayStateParams:
typedef PlayStateParams =
{
targetSong:Song,
?targetDifficulty:String,
?targetVariation:String,
?targetInstrumental:String,
?practiceMode:Bool,
?botPlayMode:Bool,
?startTimestamp:Float,
?playbackRate:Float,
?mirrored:Bool
}
targetDifficulty
String
default:"Constants.DEFAULT_DIFFICULTY"
The difficulty to play the song on
Whether the song should start in Practice Mode
Whether the song should start in Bot Play Mode
If specified, the game will jump to this timestamp (in ms) after countdown
Conductor & Timing System
The Conductor class handles musical timing throughout the game:
class Conductor
{
public var songPosition:Float = 0; // Current position in ms
public var bpm(get, never):Float; // Current BPM
public var currentBeat:Int = 0; // Current beat number
public var currentStep:Int = 0; // Current step number
public var currentMeasure:Int = 0; // Current measure number
public var currentBeatTime:Float = 0; // Beat with fractional component
public var currentStepTime:Float = 0; // Step with fractional component
public var currentMeasureTime:Float = 0; // Measure with fractional component
}
Musical Time Units
Understanding the timing hierarchy:
- Step: Quarter of a beat (4/4 time = 16 steps per measure)
- Beat: Quarter note in 4/4 time (determined by time signature denominator)
- Measure: Complete bar of music (determined by time signature numerator)
Example (4/4 time at 120 BPM):
- 120 BPM = 2 beats per second
- 1 beat = 4 steps
- 1 measure = 4 beats = 16 steps
- 120 BPM = 8 steps per second
Time Signatures
The Conductor supports variable time signatures:
public var timeSignatureNumerator:Int; // The '4' in 4/4
public var timeSignatureDenominator:Int; // The '4' in 4/4
Common time signatures:
- 4/4: 4 beats per measure, quarter note gets the beat
- 3/4: 3 beats per measure, quarter note gets the beat
- 7/8: 7 beats per measure, eighth note gets the beat
- 6/8: 6 beats per measure, eighth note gets the beat
BPM and Time Changes
Songs can have multiple BPM changes defined via SongTimeChange:
class SongTimeChange
{
public var timeStamp:Float; // When the change occurs (ms)
public var bpm:Float; // New BPM value
public var timeSignatureNum:Int; // Time signature numerator
public var timeSignatureDen:Int; // Time signature denominator
public var beatTime:Float; // Beat time at this change
public var beatTuplets:Array<Int>; // Step subdivisions
}
Timing Signals
The Conductor dispatches signals for game events:
public static var stepHit:FlxSignal; // Fired every step
public static var beatHit:FlxSignal; // Fired every beat
public static var measureHit:FlxSignal; // Fired every measure
Offsets
Multiple offset types compensate for timing discrepancies:
Offset tied to the chart to compensate for instrumental delay
Offset tied to the audio file format
User-configured offset to compensate for input lag (from save file)
User-configured offset to compensate for audio/visual lag (from save file)
public var combinedOffset(get, never):Float;
// Returns instrumentalOffset + formatOffset + globalOffset
Scoring System
FNF supports multiple scoring systems defined in Scoring.hx:
Scoring Systems
enum abstract ScoringSystem(String)
{
var LEGACY; // Week 6 and older
var WEEK7; // Week 7 system (tighter windows)
var PBOT1; // Points Based On Timing v1
}
PBOT1 Scoring (Default)
PBOT1 uses a sigmoid curve for scoring based on timing accuracy:
Judgement Thresholds:
Notes hit within 45ms are judged as “Sick”
Notes hit within 90ms are judged as “Good”
Notes hit within 135ms are judged as “Bad”
Notes hit within 160ms are judged as “Shit”
Notes beyond 160ms are missed
Score Calculation:
public static function scoreNote(msTiming:Float,
scoringSystem:ScoringSystem = PBOT1):Int
{
return switch (scoringSystem)
{
case PBOT1: scoreNotePBOT1(msTiming);
// ... other systems
}
}
static function scoreNotePBOT1(msTiming:Float):Int
{
var absTiming:Float = Math.abs(msTiming);
return switch (absTiming)
{
case(_ > PBOT1_MISS_THRESHOLD) => true:
PBOT1_MISS_SCORE; // -100
case(_ < PBOT1_PERFECT_THRESHOLD) => true:
PBOT1_MAX_SCORE; // 500
default:
// Sigmoid curve calculation
var factor:Float = 1.0 - (1.0 / (1.0 +
Math.exp(-PBOT1_SCORING_SLOPE * (absTiming - PBOT1_SCORING_OFFSET))));
Std.int(PBOT1_MAX_SCORE * factor + PBOT1_MIN_SCORE);
}
}
Score Constants:
PBOT1_MAX_SCORE: 500 points
PBOT1_MIN_SCORE: 9.0 points (minimum for hit)
PBOT1_MISS_SCORE: -100 points
PBOT1_PERFECT_THRESHOLD: 5ms (always max score)
Legacy Scoring
Step-function based scoring from older versions:
public static final LEGACY_HIT_WINDOW:Float = (10 / 60) * 1000; // 166.67ms
public static final LEGACY_SICK_SCORE:Int = 350;
public static final LEGACY_GOOD_SCORE:Int = 200;
public static final LEGACY_BAD_SCORE:Int = 100;
public static final LEGACY_SHIT_SCORE:Int = 50;
public static final LEGACY_MISS_SCORE:Int = -10;
Ranking System
Players receive ranks based on completion percentage:
enum abstract ScoringRank(String)
{
var PERFECT_GOLD; // All Sick hits
var PERFECT; // 100% completion
var EXCELLENT; // >= RANK_EXCELLENT_THRESHOLD
var GREAT; // >= RANK_GREAT_THRESHOLD
var GOOD; // >= RANK_GOOD_THRESHOLD
var SHIT; // Below GOOD threshold
}
public static function calculateRank(scoreData:SaveScoreData):ScoringRank
{
if (scoreData.tallies.sick == scoreData.tallies.totalNotes)
return ScoringRank.PERFECT_GOLD;
var completionAmount:Float =
(tallies.sick + tallies.good - tallies.missed) / tallies.totalNotes;
// Compare against threshold constants...
}
Difficulty System
Difficulty Levels
Songs support multiple difficulty levels, each with separate chart data:
Default difficulties:
easy
normal (default)
hard
Difficulty-specific data:
class SongChartData
{
public var scrollSpeed:Map<String, Float>; // Per-difficulty scroll speed
public var notes:Map<String, Array<SongNoteData>>; // Per-difficulty notes
}
Controls how fast notes approach the strumline:
public function getScrollSpeed(diff:String = 'default'):Float
{
var result:Float = this.scrollSpeed.get(diff);
if (result == 0.0 && diff != 'default')
return getScrollSpeed('default');
return (result == 0.0) ? 1.0 : result;
}
Health System
Player health affects gameplay outcome:
Health changes:
- Hitting notes increases health
- Missing notes decreases health
- Health reaches 0 = Game Over
- Health > max = clamped to maximum
Constants:
Constants.HEALTH_STARTING: Initial health value
- Health range: typically 0.0 to 2.0
Practice & Bot Modes
Practice Mode
Allows practicing without affecting scores:
- No score saving
- Can restart freely
- Debug information available
Bot Play Mode
Automatic perfect gameplay:
- All notes hit automatically
- Used for testing/demonstration
- No score saving
typedef PlayStateParams = {
// ...
?practiceMode:Bool,
?botPlayMode:Bool,
}
Player input is processed through PreciseInputManager for accurate timing:
- Timestamps are captured at frame-level precision
- Input is compared against Conductor timing
- Supports multiple input methods (keyboard, controller, touch on mobile)
The game tracks various tallies during gameplay:
class SaveScoreTallyData
{
public var sick:Int = 0; // Perfect hits
public var good:Int = 0; // Good hits
public var bad:Int = 0; // Bad hits
public var shit:Int = 0; // Poor hits
public var missed:Int = 0; // Missed notes
public var totalNotes:Int = 0; // Total notes in chart
public var combo:Int = 0; // Current combo
public var maxCombo:Int = 0; // Highest combo achieved
}
These tallies determine the final rank and are used for leaderboards and progression tracking.