JVN Facets are composable, reactive user interfaces stored in .facet properties files. A Facet defines a tree of visual nodes whose content and visibility are resolved from the live visual-novel state every time the overlay is rendered.
Use a Facet when a normal menu row list or a single-title/single-body reactive screen is not enough—for example:
- a character status card with portrait, name, statistics, and meters;
- an inventory or quest summary assembled from several visual regions;
- a chapter card with conditional artwork;
- a heads-up display panel driven by story variables;
- a shop, confirmation, or information overlay with custom presentation;
- a reusable game-specific interface that still uses standard JVN screen actions.
Facets extend JVN's existing overlay-screen lifecycle. They do not introduce another screen stack, input router, or action language. The established [screen show] and [screen call] commands, modal behavior, timers, buttons, localization, conditions, and return values all continue to apply.
For a workflow that combines reactive UI with reusable scene animation, see Reusable Authoring With Facets And Motifs. Motifs can coordinate motion around a Facet, but the current Facet contract does not animate individual nodes.
Implementation:
- model:
modules/core/src/main/java/com/jvn/core/vn/ui/VnFacetSpec.java - loader:
modules/core/src/main/java/com/jvn/core/vn/ui/VnReactiveScreenLoader.java - live binding:
modules/core/src/main/java/com/jvn/core/vn/ui/VnReactiveOverlayScreenSpec.java - JavaFX rendering:
modules/fx/src/main/java/com/jvn/fx/vn/VnRenderer.java
These systems share some capabilities but serve different shapes of UI.
| Need | Recommended system |
|---|---|
| Main menu, settings, save/load, or keyboard/controller-first row navigation | Menu profiles and .menu files |
| Small dialog with a title, body, and buttons | Reactive .screen file |
| Nested freeform presentation with several text/image/meter regions | .facet file |
| Story dialogue box, choices, NVL, or bubble presentation | Dialogue layout/style system |
A Facet is still an overlay screen. It can contain standard overlay buttons, but its visual nodes are not menu rows and do not inherit menu-profile layout behavior.
Create config/facets/status.facet:
title=Party Status
width=0.58
height=0.46
nodes=name,health_label,health
node.name.type=text
node.name.text=${player_name}
node.name.x=0.08
node.name.y=0.20
node.name.width=0.84
node.name.height=0.14
node.health_label.type=text
node.health_label.text=Health
node.health_label.x=0.08
node.health_label.y=0.42
node.health_label.width=0.84
node.health_label.height=0.10
node.health.type=bar
node.health.value=${health_ratio}
node.health.x=0.08
node.health.y=0.56
node.health.width=0.84
node.health.height=0.08
buttons=close
button.close.label=Close
button.close.action=hideSet the variables and show it from VNS:
[set player_name "Lavender"]
[set health_ratio 0.75]
[screen show status]
The runtime discovers status.facet, creates the normal reactive overlay, and renders the three Facet nodes inside its panel.
This example demonstrates nested groups, a variable-backed image, localized text, conditional content, a bar, a modal call, and a returned button value.
# config/facets/companion_card.facet
id=companion_card
title=i18n:companion.card.title
x=0.18
y=0.14
width=0.64
height=0.58
modal=true
dim=true
dismiss=false
call=true
returnKey=screen.return.companion_card
nodes=content,portrait,name,relationship_label,relationship,warning
# A nested coordinate container. Declare it before its children.
node.content.type=group
node.content.x=0.06
node.content.y=0.17
node.content.width=0.88
node.content.height=0.60
node.portrait.type=image
node.portrait.parent=content
node.portrait.value=${companion_portrait}
node.portrait.x=0.00
node.portrait.y=0.00
node.portrait.width=0.27
node.portrait.height=0.88
node.name.type=text
node.name.parent=content
node.name.text=${companion_name}
node.name.x=0.33
node.name.y=0.00
node.name.width=0.67
node.name.height=0.20
node.relationship_label.type=text
node.relationship_label.parent=content
node.relationship_label.text=i18n:companion.relationship
node.relationship_label.x=0.33
node.relationship_label.y=0.28
node.relationship_label.width=0.67
node.relationship_label.height=0.14
node.relationship.type=bar
node.relationship.parent=content
node.relationship.value=${relationship_ratio}
node.relationship.x=0.33
node.relationship.y=0.48
node.relationship.width=0.67
node.relationship.height=0.10
node.warning.type=text
node.warning.parent=content
node.warning.text=i18n:companion.relationship.low
node.warning.visibleIf=relationship_ratio < 0.25
node.warning.x=0.33
node.warning.y=0.68
node.warning.width=0.67
node.warning.height=0.18
buttons=invite,leave
button.invite.label=i18n:companion.invite
button.invite.action=return
button.invite.target=invite
button.invite.enabledIf=relationship_ratio >= 0.50
button.leave.label=i18n:common.close
button.leave.action=return
button.leave.target=leave[set companion_name "Lavender"]
[set companion_portrait "assets/characters/lavender/portrait.png"]
[set relationship_ratio 0.72]
[screen call companion_card]
[if screen.return.companion_card == "invite" goto invite_companion]
[goto continue_story]
[screen show <id>] and [screen call <id>] search in this order:
config/facets/<id>.facetconfig/screens/<id>.screenconfig/screens/<id>.propertiesscreens/<id>.screenscreens/<id>.properties<id>.screen<id>.properties<id>.facet
The first existing file wins. Consequently, config/facets/shop.facet intentionally overrides config/screens/shop.screen when both exist.
If none of these files exists, JVN falls back to the inline [screen show ...] or [screen call ...] syntax.
Recommended convention:
config/
├── facets/
│ ├── companion_card.facet
│ ├── quest_summary.facet
│ └── status.facet
└── screens/
├── confirm.screen
└── chapter_popup.screen
A .facet file uses UTF-8 Java properties syntax:
key=value- Empty lines are allowed.
- Lines beginning with
#are comments. - Keys and values are trimmed by the loader.
- Node IDs and button IDs should use
snake_case. - Duplicate properties follow normal Java
Propertiesbehavior: the last value wins.
The file contains three layers:
- overlay fields controlling the panel and lifecycle;
node.<id>.*fields defining the Facet tree;- optional
button.<id>.*fields defining interaction.
Facets accept the same top-level fields as reactive screens.
| Key | Default | Meaning |
|---|---|---|
id |
requested screen ID | Runtime overlay ID |
title |
overlay ID | Panel title; localized and interpolated at render time |
text / body |
empty | Optional legacy body rendered before Facet nodes |
visibleIf |
empty | Condition controlling the entire overlay |
x |
0.18 |
Panel X as a viewport fraction |
y |
0.18 |
Panel Y as a viewport fraction |
width / w |
0.64 |
Panel width as a viewport fraction |
height / h |
0.42 |
Panel height as a viewport fraction |
modal |
value of call |
Whether the overlay blocks interaction behind it |
dim / dimBackground |
true |
Whether the first dimming overlay draws the dark backdrop |
dismiss / dismissOnAdvance |
opposite of call |
Whether normal advance input dismisses it |
call |
false |
Marks the file as a call-style overlay |
timer |
0 |
Lifetime in milliseconds; 0 disables the timer |
timerAction |
return for calls, otherwise hide |
Action when the timer expires |
timerTarget |
empty | Timer return value or action target |
returnKey |
screen.return.<id> |
VNS variable receiving a call return value |
root |
root |
ID used for the implicit root coordinate box |
All panel dimensions are clamped by the reactive-screen model. Node dimensions are not clamped; see Geometry Rules.
The recommended form explicitly lists nodes:
nodes=container,title,portrait,meterThe list determines rendering and parent-resolution order. Declare a parent before every child that refers to it.
If nodes is absent or empty, the loader discovers IDs by scanning keys beginning with node.. Explicit nodes= is preferable because Properties key iteration does not provide a useful author-controlled visual order.
Each node uses this prefix:
node.<id>.<field>=<value>For example:
node.hero_name.type=text
node.hero_name.text=${player_name}| Field | Default | Meaning |
|---|---|---|
type |
text |
group, text, image, or bar |
parent |
root |
Parent group ID |
text |
empty | Text-node content |
value |
empty | Image asset path or bar numeric value |
visibleIf |
empty | Live VNS condition; empty means visible |
x |
0.0 |
X offset as a fraction of the parent width |
y |
0.0 |
Y offset as a fraction of the parent height |
width |
1.0 |
Width as a fraction of the parent width |
height |
0.1 |
Height as a fraction of the parent height |
Unknown type values currently produce a loader diagnostic and fall back to text.
A group is an invisible coordinate container. It does not draw a background or capture input. Its geometry becomes the coordinate space for later child nodes.
nodes=card,name,meter
node.card.type=group
node.card.x=0.08
node.card.y=0.20
node.card.width=0.84
node.card.height=0.60
node.name.type=text
node.name.parent=card
node.name.text=${player_name}
node.meter.type=bar
node.meter.parent=card
node.meter.y=0.40
node.meter.height=0.12
node.meter.value=${energy_ratio}Important: card must occur before name and meter in nodes=. If a parent has not been resolved yet, the renderer uses the root panel as the fallback coordinate space for that child.
A text node renders localized and interpolated text inside its geometry.
node.balance.type=text
node.balance.text=Coins: ${coins}
node.balance.x=0.10
node.balance.y=0.30
node.balance.width=0.80
node.balance.height=0.16Behavior:
textis passed throughLocalization.translateText.- The translated result is interpolated from live
VnStatevariables. - Text wraps on whitespace to fit the node width.
- Lines beyond the node height are clipped by omission.
- The current renderer uses the active dialogue font and Facet-default color/weight.
- There are currently no per-node typography, alignment, or color fields.
Localization examples:
node.heading.text=i18n:status.heading
node.summary.text=${player_name}: ${quest_count} questsAn image node resolves value as an asset path and draws it into the node rectangle.
node.portrait.type=image
node.portrait.value=${portrait_path}
node.portrait.x=0.06
node.portrait.y=0.20
node.portrait.width=0.26
node.portrait.height=0.56Behavior:
valueis localized and interpolated before loading.- A blank value draws nothing.
- Loading uses the normal VN image cache and asset resolution path.
- The image is scaled to the node rectangle.
- Aspect ratio is not preserved automatically; size the node accordingly.
- A failed or unresolved image draws nothing.
A bar renders a fixed background track and a filled cyan foreground.
node.health.type=bar
node.health.value=${health_ratio}
node.health.x=0.36
node.health.y=0.58
node.health.width=0.56
node.health.height=0.07Behavior:
valueis interpolated and parsed as a decimal number.- Values below
0.0clamp to0.0. - Values above
1.0clamp to1.0. - Blank or nonnumeric values use
0.0. - Use a ratio, not a percentage:
0.75, not75. - Bar colors are currently renderer defaults and are not configurable per node.
To convert game values, store the ratio in VNS or Java before showing the Facet:
[set health_ratio 0.75]
Facet geometry is hierarchical and normalized.
For a node with parent rectangle (parentX, parentY, parentWidth, parentHeight):
pixelX = parentX + parentWidth * x
pixelY = parentY + parentHeight * y
pixelWidth = parentWidth * width
pixelHeight = parentHeight * height
The renderer enforces a minimum rendered width and height of one pixel. It does not otherwise clamp nodes to the parent or viewport.
Consequences:
x=0,y=0,width=1,height=1fills the parent.x=0.25,width=0.5occupies the centered half of the parent width.- Values greater than
1may extend outside the parent. - Negative X/Y values may draw before the parent origin.
- Children are not automatically clipped to parent group bounds.
- Later nodes draw over earlier nodes.
Use explicit nodes= ordering to control both parent resolution and paint order.
Facet data is evaluated against the current VnState, not frozen when the overlay opens.
node.coins.text=Coins: ${coins}
node.portrait.value=${active_portrait}
node.health.value=${health_ratio}When one of these variables changes, the next render resolves the new value.
node.warning.visibleIf=health_ratio < 0.20
node.secret.visibleIf=persistent.trueEnding == trueConditions use VnConditionEvaluator, the same evaluator used for VNS choices and reactive-screen button conditions. If an expression is blank, the node is visible. If evaluation throws, the runtime uses the safe fallback of visible.
Persistent values can participate when mirrored into VNS variables with the persistent.<key> convention.
Top-level visibleIf controls the complete overlay:
visibleIf=ui.status_open == trueWhen this becomes false, the runtime treats the reactive overlay as not currently visible.
Facet visual nodes are presentation-only in the current contract. Interaction uses the established overlay buttons.
buttons=accept,cancel
button.accept.label=Accept
button.accept.action=return
button.accept.target=accepted
button.accept.enabledIf=coins >= price
button.cancel.label=Cancel
button.cancel.action=return
button.cancel.target=cancelledButton fields:
| Field | Default | Meaning |
|---|---|---|
label |
button ID | Localized/interpolated label |
action |
noop |
Overlay/VNS action |
target |
empty | Action target or returned value |
enabled |
true |
Static enabled state |
enabledIf |
empty | Live enabled condition |
visibleIf |
empty | Live visibility condition |
x, y |
automatic row | Position |
width, height |
automatic | Size |
space / coordinateSpace |
screen |
screen or viewport |
Common actions:
| Action | Result |
|---|---|
return |
Writes target to the return key and closes the called overlay |
hide, close, dismiss |
Closes an overlay |
goto |
Jumps to a VNS label |
advance |
Advances the VN scene |
screen |
Runs another screen command |
set, flag, unflag, clear, inc, dec |
Routes to variable interop |
persistent |
Routes to persistent interop |
See Reactive Overlay Screens and Menu Actions And Navigation for the shared action behavior.
Current interaction boundaries:
- Facet
text,image,bar, andgroupnodes do not receive pointer events. - Buttons are not children of groups; they use overlay screen or viewport coordinates.
- There is no Facet node focus tree, scrolling container, input field, slider, drag target, or node-level action yet.
[screen show status]
Story execution continues. This is appropriate for informational or HUD-like overlays.
[screen call companion_card]
[if screen.return.companion_card == "invite" goto invite_companion]
Use buttons with action=return and a target value. Called screens default to modal behavior and do not default to advance-to-dismiss.
[screen hide status]
Facets inherit reactive-screen timers:
timer=3000
timerAction=hideFor a timed call:
call=true
timer=5000
timerAction=return
timerTarget=timeout[screen call timed_prompt]
[if screen.return.timed_prompt == "timeout" goto missed_prompt]
The current renderer displays the standard overlay timer indicator. The indicator's visual ratio uses the overlay renderer's existing timer presentation.
The loader records diagnostics for:
- a blank requested screen ID;
- asset-loading failures;
- invalid numeric properties;
- invalid timer integers;
- unknown Facet node types.
Safe fallbacks are used where possible:
| Problem | Fallback |
|---|---|
| Unknown node type | text |
| Invalid node number | Field default |
Failed visibleIf evaluation |
Visible |
| Nonnumeric bar value | 0.0 |
| Missing parent at render time | Root panel |
| Blank image path | Nothing drawn |
The loader does not currently reject duplicate node IDs, parent cycles, children declared before parents, off-panel geometry, or unresolved interpolation variables. Keep explicit node ordering and test the Facet in the runtime preview.
nodes=content,...
node.content.type=group
node.content.x=0.06
node.content.y=0.18
node.content.width=0.88
node.content.height=0.64Place all visual content inside content so the panel title and buttons have predictable space.
node.warning.type=text
node.warning.text=Low health
node.warning.visibleIf=health_ratio < 0.20node.portrait.type=image
node.portrait.value=${active_portrait}Change active_portrait while the Facet is open to change the rendered asset.
nodes=frame,portrait,badgeBecause later nodes paint later, badge appears over portrait, which appears over frame.
Use Facet nodes for custom presentation and normal overlay buttons for decisions:
nodes=illustration,prompt
node.illustration.type=image
node.illustration.value=assets/ui/decision.png
node.prompt.type=text
node.prompt.text=Spend ${price} coins?
buttons=yes,no
button.yes.action=return
button.yes.target=yes
button.yes.enabledIf=coins >= price
button.no.action=return
button.no.target=noStart with an existing screen:
# config/screens/status.screen
title=Status
text=Coins: ${coins}
buttons=close
button.close.action=hideCreate config/facets/status.facet, retain the panel and button fields, and replace the single body with nodes:
title=Status
nodes=coins,health
node.coins.type=text
node.coins.text=Coins: ${coins}
node.health.type=bar
node.health.value=${health_ratio}
node.health.y=0.30
buttons=close
button.close.action=hideNo VNS call-site change is necessary because both use [screen show status] or [screen call status].
The first Facet contract deliberately establishes a small, stable foundation:
- property-backed
.facetfiles; - ordered nested geometry;
group,text,image, andbarnodes;- live interpolation and conditional visibility;
- integration with established overlay buttons and lifecycle.
Not currently implemented:
- automatic row, column, grid, or wrap layout;
- reusable Facet includes/components;
- loops over collections;
- node parameters or local scope;
- per-node typography, color, borders, backgrounds, or image fit modes;
- node-level actions, hover states, keyboard focus, or accessibility semantics;
- scroll/viewport nodes, inputs, sliders, or drag-and-drop;
- Puppeteer animation attached directly to Facet nodes;
- automatic clipping to parent geometry.
These are extension points for the Facet node model, not features silently implied by the current format.
| Symptom | Likely cause | Fix |
|---|---|---|
Legacy .screen appears instead |
Facet is not at the higher-priority path | Put it at config/facets/<id>.facet |
| Child is positioned against the whole panel | Parent was missing or declared after the child | Put the parent earlier in nodes= |
| Nodes overlap unexpectedly | Paint order follows nodes= |
Reorder the list or adjust geometry |
| Image is stretched | Image fills node rectangle | Match the node aspect ratio to the asset |
| Image does not appear | Blank/unresolved path or load failure | Verify the interpolated asset path |
| Bar is empty | Value is blank, nonnumeric, or 0 |
Store a decimal ratio such as 0.75 |
| Bar is always full | A percentage such as 75 clamps to 1 |
Divide to a 0.0–1.0 ratio first |
| Conditional node never hides | Expression is invalid and safe-falls to visible | Check the condition and variable names |
| Text is cut off | Node is too short for wrapped lines | Increase node height or shorten the text |
| Button does not align with a group | Buttons do not use Facet parent geometry | Position it in screen coordinates |