Godot 4.7.1 · offline action RPG

Mossfall

Side-scrolling combat with readable hits, procedural slash art, and HUD systems built to feel intentional.

Case study

Combat VFX

AttackFx volumes drive both the hit and the art. Directional effects face with the caster; caster-centered bursts stay centered. These loops approximate what ships in _draw and sheet blits.

How it works

Sweep heel → tip

Slash progress is age over hitbox duration. The wedge grows from the heel, holds, then fades with the AttackFx volume — one node, not a volley of hitboxes.

Facing left mirrors sheet dest in place (negate width only). Godot abs()s size and does not move the origin — shifting the rect would draw into the player.

# draw_slash.gd — sweep reveal
var progress := clampf(age / duration, 0.0, 1.0)
var reveal := _ease_out(clampf(progress / 0.55, 0.0, 1.0))
var pts := _wedge_pts(box, fx, facing, reveal)
canvas.draw_colored_polygon(pts, fill)

# sheet_atlas.gd — flip in place
var dest := Rect2(center - sz * 0.5, sz)
if facing < 0:
    dest.size.x = -sz.x

Showcase

Selected systems

A few shipped pieces with enough context to show craft.

01

Damage Dummy meter

Session DPS, skill bars, honest combat time

Training room

Damage Dummy meter

Bottom-right HUD on the TEST map. Current session plus the last five snapshots, total damage/DPS, and per-skill bars tinted by job.

Combat time for DPS is last hit minus first hit. A five-second idle dropout freezes the window so standing still doesn’t inflate or deflate the rate.

# mob.gd — Dummy DPS denominator
func _dummy_elapsed() -> float:
    if _dummy_combat:
        var now := _dummy_now()
        if now - _dummy_last >= DUMMY_HOLD:
            return maxf(0.0, _dummy_last - _dummy_start)
        return maxf(0.0, now - _dummy_start)
    return _dummy_frozen_elapsed

# payload.dps = total / max(elapsed, 1.0) while live
02

Combat hits & floaters

Same-tick query, world-drawn numbers that don’t smear

Combat feel

Hits & floaters

Damage uses a same-tick hurtbox query — never Area2D.get_overlapping_areas(), which lags a physics step. Multi-hit skills roll on one AttackFx; floaters are world _draw nodes with interpolation off so camera motion doesn’t smear the numbers.

# damage_number.gd
static func spawn(world, global_pos, amount, kind) -> DamageNumber:
    var n := DamageNumber.new()
    n.physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF
    world.add_child(n)
    n.global_position = global_pos
    n.reset_physics_interpolation()
    n.setup(amount, kind)
    return n
03

Hotbar & macros

Drag-only assign, macros that fire like a key

Input & HUD

Hotbar & macros

Spellbook left-click opens detail; assign is drag-only so browsing never silently arms a slot. Macros on the bar skip only CD/MP gates and fire like a key press. Extra bars use exact Shift/Ctrl binds; LineEdit focus must not eat 1–0.

# Hotbar fire path (concept)
# Macro slot → same cast pipeline as keybind
if slot.is_macro():
    for step in slot.steps:
        if not can_pay(step):
            continue
        cast_skill(step.skill_id)
else:
    cast_skill(slot.skill_id)
04

Affix crafting story

Wisps, scrolls, REQ level as ilvl — high level only

Progression

Affix crafting story

Wisps add or reroll affixes; Enchanting scrolls handle dedicated rerolls. Natural affix tier bands follow the item’s REQ LVL (ilvl) — starters at 0 roll as ilvl 1 → T9 only. No full tier tables here; the idea is the rule.

# Affix ilvl = template req_level
var ilvl := maxi(1, int(template.get("req_level", 0)))
# Frostfen Edge req 30 → best T6 (pool T6–T9)
# Affix Rune req 50 → T4–T9
var band := tier_band_for_ilvl(ilvl)
05

Camera & map framing

Floor gutter so the ground never kisses the bezel

Presentation

Camera & map framing

Vertical framing keeps a floor gutter so platforms don’t sit on the screen edge. Canvas size changes re-apply limits so letterboxing never eats the playable strip.

# Camera follows player with a bottom gutter
var view_h := get_viewport_rect().size.y / zoom.y
var floor_y := map_bounds.end.y
var target_y := clampf(
    player.global_position.y,
    map_bounds.position.y + view_h * 0.5,
    floor_y - view_h * 0.5 + floor_gutter_px
)

About

Built in Godot

Mossfall is an offline side-scrolling action RPG prototype. This page is a portfolio slice — demos and short excerpts from systems that already run in-engine.

Godot 4.7.1 Compatibility renderer 2D action RPG JSON-driven data Offline / solo