Fresh start: replace with naxIO/netfox-cs-sample foundation

Complete replacement of the tactical-shooter project with the
netfox-cs-sample (MIT) — a CS 1.6 inspired multiplayer FPS built
with Godot 4 and netfox.

## What's new
- Full CS-style gameplay: teams (T/CT), rounds, economy, buy menu
- 6 weapons: Knife, Glock, USP, AK-47, M4A1, AWP
- Bomb plant/defuse with 2 bombsites
- Flashbang & smoke grenades
- Proper netfox rollback netcode at 64 tick
- Network popup UI for host/join
- HUD, crosshair, round timer, scoreboard
- All netfox singletons registered as autoloads (works in exported builds)

## Architecture
- Listen-server (host from client, no dedicated server binary)
- Multiplayer-fps game lives at examples/multiplayer-fps/
- Netfox addons registered as autoloads for exported build compat
- Godot 4.7 with Forward+ renderer

## Removed
- Old headless-server architecture (client_main, server_main, player.gd, etc.)
- Custom netfox bootstrap with ENet fallback
- Old ChaffGames FPS template (2,420 lines, 844 KB)
- SimulationServer GDExtension stub
- Godot-jolt physics (netfox sample uses default Godot physics)
- Duplicate weapon_data.gd, anti_cheat.gd, round_manager.gd, etc.
- Server browser API Python venv (87 MB)
- test_range map and modular assets

## Preserved
- Git history
- Server config at config/default_server_config.cfg
- Windows export preset
- Build directory (gitignored)

Co-authored-by: naxIO <naxIO@users.noreply.github.com>
This commit is contained in:
2026-07-02 20:55:20 -04:00
parent ce39b237c3
commit b0c83af092
4416 changed files with 57418 additions and 902676 deletions
+1
View File
@@ -0,0 +1 @@
-4
View File
@@ -1,4 +0,0 @@
# Architecture Overview
See PROJECT_PLAN.md for the full build plan.
This document will be expanded as the project progresses.
-170
View File
@@ -1,170 +0,0 @@
# Tactical Shooter — Art Style Guide
> **Phase 3 deliverable · Modular wall/floor kit, PBR materials at 1K**
> Valorant-direction competitive FPS art direction · Godot 4 Forward+ renderer
---
## 1. Visual Direction
### Target: Valorant-Tier, Not AAA
- **Clean silhouettes** — geometry reads instantly at combat ranges. Avoid noisy trim/paneling.
- **Team-color zones** — CT (blue) and T (red/orange) territories are visually distinct at a glance. Color is used for gameplay read, not decorative variety.
- **Low-fi surface detail** — we suggest roughness and normal variation rather than carving it into geometry. 1K textures carry the detail budget.
- **No photorealism** — stylised realism with clear material categories: concrete, metal, tile, painted surfaces. Each category is immediately identifiable by its roughness/specular signature.
### What This Game Looks Like
| Aspect | Choice | Rationale |
|--------|--------|-----------|
| Lighting | Baked lightmaps + reflection probes | No real-time GI cost |
| Shadows | Baked for static, single cascade for dynamic | 60fps target on GTX 1050 |
| Reflections | Reflection probes at junctions | No SSR/SSAO cost |
| Post-processing | Tonemap only (ACES), no bloom, no DoF | Competitive clarity |
| Fog | Minimal distance fog | Don't hide silhouette readability |
| Textures | 1K PBR (1024 max), no 2K+ | VRAM budget: 2GB texture pool |
---
## 2. Modular Grid & Dimensions
The kit is built on a **2.56m (256cm) base grid** — close to 8ft imperial, powers-of-two friendly for texture tiling.
### Standard Dimensions (meters)
| Piece | Width | Height | Depth | Notes |
|-------|-------|--------|-------|-------|
| Wall straight | 2.56 | 2.56 | 0.16 | Full wall panel |
| Wall corner (L) | 2.56 × 2 leg | 2.56 | 0.16 | 90° outside corner |
| Wall doorway | 2.56 | 2.56 | 0.16 | 0.80m × 2.00m door cutout |
| Wall window | 2.56 | 2.56 | 0.16 | 1.20m × 0.80m window cutout |
| Wall endcap | 0.32 | 2.56 | 0.16 | Edge trim / termination |
| Floor slab | 2.56 | 0.08 | 2.56 | Thin slab |
| Pillar (square) | 0.64 | 2.56 | 0.64 | Structural column |
| Ceiling beam | 3.84 | 0.32 | 0.32 | Metal beam, 1.5U length |
| Accent panel | 1.28 | 0.64 | 0.08 | Team-color wall accent |
### Grid Alignment
- All pieces snap to a 1.28m (half-unit) grid in the map editor.
- Wall thickness is always 0.16m. Floors are 0.08m.
- Doorway bottom is flush with floor level. Window center is 1.58m from floor (eye level).
- Pillars occupy 0.64m × 0.64m on the floor grid (¼ of a full tile).
---
## 3. Material Library
### Material Standards
Every material is a **StandardMaterial3D** with PBR textures at **1024×1024 (1K)**:
| Texture Map | Format | Notes |
|-------------|--------|-------|
| Base Color | sRGB PNG | Diffuse/albedo with subtle colour variation |
| Normal | Linear PNG | From height map via Sobel, strength scaled per material |
| Roughness | Linear PNG (grayscale) | White = rough (255), Black = smooth (0) |
| Metallic | Linear PNG (grayscale) | White = metal (255), Black = dielectric (0) |
Texture filter: **Linear** (no mipmaps for modular kit; mipmap cost is unnecessary at 1K on a 2.56m tile with the camera ≤40m from most surfaces).
### Material Palette (7 materials)
| # | Name | Roughness | Metallic | Normal Scale | UV Tiling | Use Case |
|---|------|-----------|----------|--------------|-----------|----------|
| 1 | `wall_concrete_01` | 0.85 | 0.0 | 0.5 | 1.0 | Primary wall — light grey concrete |
| 2 | `wall_concrete_02` | 0.75 | 0.0 | 0.4 | 1.0 | Accent wall — darker grey |
| 3 | `floor_tile_01` | 0.60 | 0.0 | 1.0 | 2.0 | Interior — ceramic checkered tile |
| 4 | `floor_concrete_01` | 0.90 | 0.0 | 0.8 | 2.0 | Industrial — dark warehouse floor |
| 5 | `metal_structural_01` | 0.30 | 1.0 | 0.3 | 1.0 | Beams, vents, supports |
| 6 | `accent_team_blue` | 0.35 | 0.5 | 0.3 | 1.0 | CT territory — blue painted panels |
| 7 | `accent_team_red` | 0.35 | 0.5 | 0.3 | 1.0 | T territory — red/orange painted panels |
### Team Color Usage
- **CT (Blue)**: RGB ~(40, 80, 200) — deep blue, low saturation. Applied to accent panels, not primary walls.
- **T (Red/Orange)**: RGB ~(200, 60, 30) — burnt red-orange. Applied to accent panels, not primary walls.
- Neutral zones (uncontested) use `wall_concrete_01/02` and `floor_concrete_01`.
- Spike plant zones / bomb sites get team-colour floor accents.
---
## 4. Technical Specifications
### Texture Budget
| Category | Count | Resolution | VRAM (approx) |
|----------|-------|------------|---------------|
| Wall materials (2) | 8 maps | 1K | ~21 MB |
| Floor materials (2) | 8 maps | 1K | ~21 MB |
| Metal (1) | 4 maps | 1K | ~10 MB |
| Team accents (2) | 8 maps | 1K | ~21 MB |
| **Total (7 materials)** | **28 maps** | **1K** | **~75 MB** |
VRAM headroom for lighting data, shadow maps, and models: ~1.9 GB of the 2 GB budget.
### Performance Targets (per the visuals architecture)
| Metric | Target |
|--------|--------|
| Frame time | 16ms (60fps) |
| GPU budget | 8ms |
| CPU budget | 6ms (6ms headroom for gameplay) |
| Draw calls | 15002000/frame |
| LOD0 tris/wall | 5002000 |
### LOD Strategy (for future mesh pass)
When modular CSG pieces are replaced with custom meshes:
| LOD | Distance | Triangle Budget | Notes |
|-----|----------|-----------------|-------|
| LOD0 | 015m | 100% | Full detail |
| LOD1 | 1540m | 50% | Remove panel gaps, merge edges |
| LOD2 | 4080m | 25% | Planar collapse, decimate |
| LOD3 | 80m+ | Cull | Not visible at competitive sightlines |
---
## 5. Lighting Guidelines (for t_p3_lighting)
- **LightmapGI** on all static geometry. Texel density: 48 per unit on walls, 816 on focal surfaces.
- **Reflection probes** at corridor junctions, open plaza areas, chokepoints. Probe box size: 4U (10.24m) for corridors, 8U+ for open areas.
- **No SDFGI**, no real-time GI. 1-2 dynamic directional lights maximum (sun + maybe a single fill).
- Lightmap UV2: all modular CSG pieces export lightmap UVs automatically.
---
## 6. Mapping Guidelines (for map makers)
- **Start every map** by placing a `KitDemo` scene to see the palette.
- Snap walls to the 1.28m half-grid. Full grid is 2.56m.
- Doorway walls are exactly 2.56m wide × 2.56m tall — stack a wall piece on top if headroom is needed.
- Use `wall_concrete_02` for interior partitions, `wall_concrete_01` for exterior-facing walls.
- Cover all floor areas with `floor_tile_01` (interiors) or `floor_concrete_01` (exteriors/warehouses).
- Team accent panels are decorative only — they do not affect gameplay collision or hitbox.
---
## 7. Export & Build Pipeline
- Client project lives in `client/` with its own `project.godot`.
- Modular kit scenes are under `client/assets/scenes/modular/`.
- Materials under `client/assets/materials/`.
- Textures under `client/assets/textures/<material_name>/`.
- Textures are generated by `client/assets/textures/generate_textures.py`.
- Materials are generated by `client/assets/materials/generate_materials.py`.
- Scenes are generated by `client/assets/scenes/modular/generate_scenes.py`.
### Adding a New Material
1. Add a generator function in `generate_textures.py`
2. Run it to produce 1K PBR maps
3. Add a `.tres` entry in `generate_materials.py`
4. Run it to produce the material resource
5. Optionally create a scene using the new material
---
*This guide is a living document — update as the visual direction evolves.*
@@ -0,0 +1,182 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="384"
height="256"
viewBox="0 0 101.6 67.733332"
version="1.1"
id="svg1"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
sodipodi:docname="netfox-1st-anniversary-banner.svg"
inkscape:export-filename="netfox-anniversary-banner.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="1.4142136"
inkscape:cx="126.57211"
inkscape:cy="92.630986"
inkscape:window-width="2560"
inkscape:window-height="1048"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1">
<pattern
inkscape:collect="always"
xlink:href="#pattern6-7"
preserveAspectRatio="xMidYMid"
id="pattern31"
patternTransform="translate(190,350)" />
<pattern
patternUnits="userSpaceOnUse"
width="29"
height="20"
patternTransform="translate(190,350)"
preserveAspectRatio="xMidYMid"
style="fill:#000000"
id="pattern6-7"
inkscape:label="Abstract 1"
inkscape:collect="always"
inkscape:isstock="true">
<path
style="fill:none;stroke:#000000;stroke-width:10;stroke-linecap:butt;stroke-dasharray:none;paint-order:stroke markers fill;stop-color:#000000"
d="M 0,5 C 19,5 10,15 29,15"
id="path4-2"
sodipodi:nodetypes="cc" />
</pattern>
<linearGradient
id="swatch25"
inkscape:swatch="solid">
<stop
style="stop-color:#8700ff;stop-opacity:1;"
offset="0"
id="stop25" />
</linearGradient>
</defs>
<g
inkscape:label="Logo"
inkscape:groupmode="layer"
id="layer1">
<path
id="rect1"
style="fill:#ff8100;fill-opacity:1;stroke:none;stroke-width:0.822685;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
d="M 16.933326,25.374936 V 42.358395 L 33.816533,59.241602 50.799992,42.258144 42.333321,25.374936 Z"
sodipodi:nodetypes="cccccc" />
<path
d="m 33.866666,25.374936 -8.46667,16.933341 -8.46667,-16.933341 z"
fill="#5fff97"
id="path1"
style="stroke-width:4.23333" />
<path
d="M 50.799992,42.308277 42.333322,25.374936 33.866666,42.308277 Z"
fill="#ff5f5f"
id="path2"
style="stroke-width:4.23333" />
<rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.529167;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
id="rect2"
width="3.6265092"
height="3.6265092"
x="47.87698"
y="11.955959"
transform="rotate(45)" />
<rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.529167;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
id="rect3"
width="3.6265092"
height="3.6265092"
x="59.850655"
y="-0.017714709"
transform="rotate(45)" />
<rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.529167;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
id="rect4"
width="3.6265092"
height="3.6265092"
x="62.175529"
y="14.351729"
transform="rotate(45)" />
<text
xml:space="preserve"
style="font-size:11.2889px;fill:#ff8100;stroke-width:0.529167;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
x="54.283672"
y="46.337658"
id="text1"><tspan
sodipodi:role="line"
id="tspan1"
style="font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:11.2889px;font-family:'Liberation Sans';-inkscape-font-specification:'Liberation Sans';stroke-width:0.529167"
x="54.283672"
y="46.337658">netfox</tspan></text>
</g>
<g
inkscape:groupmode="layer"
id="layer3"
inkscape:label="Hat"
style="display:inline">
<path
id="path16"
style="display:inline;fill:#8700ff;fill-opacity:1;stroke:#8800ff;stroke-width:4.23333;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 36.380864,56.677274 8.967201,-0.02336"
sodipodi:nodetypes="cc"
inkscape:label="party-blower-handle" />
<path
sodipodi:type="spiral"
style="display:inline;fill:none;fill-rule:evenodd;stroke:#c380ff;stroke-width:4.23333;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
id="path17"
sodipodi:cx="42.308262"
sodipodi:cy="56.665592"
sodipodi:expansion="1"
sodipodi:revolution="3"
sodipodi:radius="3.7953792"
sodipodi:argument="-18.849556"
sodipodi:t0="0"
d="m 42.308262,56.665592 c 0.190294,0 0.09388,0.256513 0,0.316282 -0.254421,0.161969 -0.554562,-0.07123 -0.632563,-0.316282 -0.139526,-0.438332 0.214595,-0.860149 0.632563,-0.948845 0.613385,-0.130164 1.171185,0.358597 1.265126,0.948845 0.125209,0.786708 -0.502655,1.484327 -1.265126,1.581408 -0.959492,0.122166 -1.798514,-0.646699 -1.89769,-1.581408 -0.120115,-1.132057 0.790727,-2.113297 1.89769,-2.213971 1.304517,-0.11864 2.428455,0.934743 2.530253,2.213971 0.117529,1.47692 -1.078751,2.743862 -2.530253,2.846535 -1.649289,0.116663 -3.059443,-1.222754 -3.162816,-2.846535 -0.115969,-1.821636 1.366753,-3.375151 3.162816,-3.479097 1.99397,-0.115401 3.690955,1.51075 3.795379,3.479097"
transform="rotate(90,45.731692,56.281965)"
inkscape:label="party-blower-tongue" />
<path
id="path14"
style="display:inline;mix-blend-mode:normal;fill:#8700ff;fill-opacity:1;stroke:none;stroke-width:1.5875;stroke-linejoin:round;stroke-opacity:1"
d="M 16.933314,25.374936 29.633317,0.050124 42.333321,25.374936 Z"
sodipodi:nodetypes="cccc"
inkscape:label="hat" />
<path
id="path29"
style="opacity:0.25;mix-blend-mode:normal;fill:url(#pattern31);fill-opacity:1;stroke:none;stroke-width:1.5875;stroke-linejoin:round;stroke-opacity:1"
d="M 16.933314,25.374936 29.633317,0.050124 42.333321,25.374936 Z"
sodipodi:nodetypes="cccc"
inkscape:label="hat-pattern" />
</g>
<g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="Candle"
style="display:none">
<path
id="path7"
style="fill:#ff8100;fill-opacity:1;stroke:none;stroke-width:1.5875;stroke-linejoin:round"
d="m 33.854122,21.154135 -4.220801,-4.220802 4.233335,-8.4416035 4.233332,8.4416035 -4.220805,4.220802 z"
sodipodi:nodetypes="cccccc" />
<path
id="path8"
style="fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:0.79375;stroke-linejoin:round"
d="m 33.860388,21.160399 -2.1104,-2.110401 2.116667,-4.220801 2.116666,4.220801 -2.110402,2.110401 z"
sodipodi:nodetypes="cccccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.2 MiB

@@ -0,0 +1,171 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="256"
height="256"
viewBox="0 0 67.733332 67.733332"
version="1.1"
id="svg1"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
sodipodi:docname="netfox-1st-anniversary.svg"
inkscape:export-filename="netfox-candle.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="2"
inkscape:cx="106"
inkscape:cy="122"
inkscape:window-width="2560"
inkscape:window-height="1048"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer3" />
<defs
id="defs1">
<pattern
inkscape:collect="always"
xlink:href="#pattern6-7"
preserveAspectRatio="xMidYMid"
id="pattern31"
patternTransform="translate(190,350)" />
<pattern
patternUnits="userSpaceOnUse"
width="29"
height="20"
patternTransform="translate(190,350)"
preserveAspectRatio="xMidYMid"
style="fill:#000000"
id="pattern6-7"
inkscape:label="Abstract 1"
inkscape:collect="always"
inkscape:isstock="true">
<path
style="fill:none;stroke:#000000;stroke-width:10;stroke-linecap:butt;stroke-dasharray:none;paint-order:stroke markers fill;stop-color:#000000"
d="M 0,5 C 19,5 10,15 29,15"
id="path4-2"
sodipodi:nodetypes="cc" />
</pattern>
<linearGradient
id="swatch25"
inkscape:swatch="solid">
<stop
style="stop-color:#8700ff;stop-opacity:1;"
offset="0"
id="stop25" />
</linearGradient>
</defs>
<g
inkscape:label="Logo"
inkscape:groupmode="layer"
id="layer1">
<path
id="rect1"
style="fill:#ff8100;fill-opacity:1;stroke:none;stroke-width:0.822685;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
d="M 16.933326,25.374936 V 42.358395 L 33.816533,59.241602 50.799992,42.258144 42.333321,25.374936 Z"
sodipodi:nodetypes="cccccc" />
<path
d="m 33.866666,25.374936 -8.46667,16.933341 -8.46667,-16.933341 z"
fill="#5fff97"
id="path1"
style="stroke-width:4.23333" />
<path
d="M 50.799992,42.308277 42.333322,25.374936 33.866666,42.308277 Z"
fill="#ff5f5f"
id="path2"
style="stroke-width:4.23333" />
<rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.529167;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
id="rect2"
width="3.6265092"
height="3.6265092"
x="47.87698"
y="11.955959"
transform="rotate(45)" />
<rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.529167;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
id="rect3"
width="3.6265092"
height="3.6265092"
x="59.850655"
y="-0.017714709"
transform="rotate(45)" />
<rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.529167;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
id="rect4"
width="3.6265092"
height="3.6265092"
x="62.175529"
y="14.351729"
transform="rotate(45)" />
</g>
<g
inkscape:groupmode="layer"
id="layer3"
inkscape:label="Hat"
style="display:inline">
<path
id="path16"
style="display:inline;fill:#8700ff;fill-opacity:1;stroke:#8800ff;stroke-width:4.23333;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
d="m 36.380864,56.677274 8.967201,-0.02336"
sodipodi:nodetypes="cc"
inkscape:label="party-blower-handle" />
<path
sodipodi:type="spiral"
style="display:inline;fill:none;fill-rule:evenodd;stroke:#c380ff;stroke-width:4.23333;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1"
id="path17"
sodipodi:cx="42.308262"
sodipodi:cy="56.665592"
sodipodi:expansion="1"
sodipodi:revolution="3"
sodipodi:radius="3.7953792"
sodipodi:argument="-18.849556"
sodipodi:t0="0"
d="m 42.308262,56.665592 c 0.190294,0 0.09388,0.256513 0,0.316282 -0.254421,0.161969 -0.554562,-0.07123 -0.632563,-0.316282 -0.139526,-0.438332 0.214595,-0.860149 0.632563,-0.948845 0.613385,-0.130164 1.171185,0.358597 1.265126,0.948845 0.125209,0.786708 -0.502655,1.484327 -1.265126,1.581408 -0.959492,0.122166 -1.798514,-0.646699 -1.89769,-1.581408 -0.120115,-1.132057 0.790727,-2.113297 1.89769,-2.213971 1.304517,-0.11864 2.428455,0.934743 2.530253,2.213971 0.117529,1.47692 -1.078751,2.743862 -2.530253,2.846535 -1.649289,0.116663 -3.059443,-1.222754 -3.162816,-2.846535 -0.115969,-1.821636 1.366753,-3.375151 3.162816,-3.479097 1.99397,-0.115401 3.690955,1.51075 3.795379,3.479097"
transform="rotate(90,45.731692,56.281965)"
inkscape:label="party-blower-tongue" />
<path
id="path14"
style="display:inline;mix-blend-mode:normal;fill:#8700ff;fill-opacity:1;stroke:none;stroke-width:1.5875;stroke-linejoin:round;stroke-opacity:1"
d="M 16.933314,25.374936 29.633317,0.050124 42.333321,25.374936 Z"
sodipodi:nodetypes="cccc"
inkscape:label="hat" />
<path
id="path29"
style="opacity:0.25;mix-blend-mode:normal;fill:url(#pattern31);fill-opacity:1;stroke:none;stroke-width:1.5875;stroke-linejoin:round;stroke-opacity:1"
d="M 16.933314,25.374936 29.633317,0.050124 42.333321,25.374936 Z"
sodipodi:nodetypes="cccc"
inkscape:label="hat-pattern" />
</g>
<g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="Candle"
style="display:none">
<path
id="path7"
style="fill:#ff8100;fill-opacity:1;stroke:none;stroke-width:1.5875;stroke-linejoin:round"
d="m 33.854122,21.154135 -4.220801,-4.220802 4.233335,-8.4416035 4.233332,8.4416035 -4.220805,4.220802 z"
sodipodi:nodetypes="cccccc" />
<path
id="path8"
style="fill:#ffff00;fill-opacity:1;stroke:none;stroke-width:0.79375;stroke-linejoin:round"
d="m 33.860388,21.160399 -2.1104,-2.110401 2.116667,-4.220801 2.116666,4.220801 -2.110402,2.110401 z"
sodipodi:nodetypes="cccccc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 896 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="272"
height="128"
viewBox="0 0 71.966666 33.866666"
version="1.1"
id="svg1"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
sodipodi:docname="netfox-banner-hor-alt.svg"
inkscape:export-filename="netfox-banner.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#bfbfbf"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="2.8284272"
inkscape:cx="112.78353"
inkscape:cy="52.3259"
inkscape:window-width="2560"
inkscape:window-height="1048"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="g1-5" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g1">
<path
id="rect1"
style="fill:#ff8100;fill-opacity:1;stroke:none;stroke-width:0.822685;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
d="m 0,0 12.009119,12.009119 h 23.87646 v -24.018238 l -17.92507,-5.95139 z"
transform="rotate(45)"
sodipodi:nodetypes="cccccc" />
<path
d="M 16.93334,0 8.4666699,16.93334 0,0 Z"
fill="#5fff97"
id="path1"
style="stroke-width:4.23333" />
<path
d="M 33.866666,16.93334 25.399996,0 16.93334,16.93334 Z"
fill="#ff5f5f"
id="path2"
style="stroke-width:4.23333" />
<rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.529167;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
id="rect2"
width="3.6265092"
height="3.6265092"
x="17.96052"
y="5.9868398"
transform="rotate(45)" />
<rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.529167;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
id="rect3"
width="3.6265092"
height="3.6265092"
x="29.934195"
y="-5.9868345"
transform="rotate(45)" />
<rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.529167;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
id="rect4"
width="3.6265092"
height="3.6265092"
x="32.259071"
y="8.3826094"
transform="rotate(45)" />
</g>
<g
id="g1-5"
transform="matrix(0.26458333,0,0,0.26458333,-190.26843,-380.00464)">
<text
id="netfox"
class="cls-1"
x="851.6261"
y="1516.0247"
style="font-weight:300;font-size:42.6667px;font-family:Oxanium;fill:#ff8100">net<tspan
class="cls-2"
id="tspan1"
style="font-size:42.6667px;fill:#000000;fill-opacity:1">fox</tspan></text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="272"
height="128"
viewBox="0 0 71.966666 33.866666"
version="1.1"
id="svg1"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
sodipodi:docname="netfox-banner-hor-black.svg"
inkscape:export-filename="netfox-banner.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xml:space="preserve"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#bfbfbf"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="2.8284272"
inkscape:cx="125.1579"
inkscape:cy="44.901279"
inkscape:window-width="2560"
inkscape:window-height="1048"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" /><defs
id="defs1" /><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"><g
id="g1-5"
transform="matrix(0.26458333,0,0,0.26458333,-190.26843,-380.00464)"
style="fill:#000000;fill-opacity:1"><text
id="netfox"
class="cls-1"
x="851.6261"
y="1516.0247"
style="font-weight:300;font-size:42.6667px;font-family:Oxanium;fill:#000000;fill-opacity:1">net<tspan
class="cls-2"
id="tspan1"
style="font-size:42.6667px;fill:#000000;fill-opacity:1">fox</tspan></text></g><g
id="g2"><path
id="rect1-3"
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.80983;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
d="M 0.3180025,0.21523374 V 16.933327 l 14.0952875,14.095287 2.524119,-2.524627 2.524118,2.524627 14.144631,-14.144631 h -8.285032 l 2.524119,2.52412 -2.524119,2.524627 -2.524118,-2.524627 2.524118,-2.52412 H 16.986751 L 25.321127,0.21523374 H 16.986752 L 8.6523775,16.883984 11.176497,19.408103 8.6523776,21.932731 6.1282584,19.408103 8.6523775,16.883984 Z m 25.0031245,0 8.318096,16.63568426 0.01628,-0.01628 z" /><path
style="fill:none;stroke:#000000;stroke-width:0.521026;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:6;stroke-opacity:1"
d="M 16.88398,33.606161 0.2605135,16.982693 V 0.26051574 H 25.269743 L 33.606158,16.883983 Z"
id="path3" /></g></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="272"
height="128"
viewBox="0 0 71.966666 33.866666"
version="1.1"
id="svg1"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
sodipodi:docname="netfox-banner-hor-white.svg"
inkscape:export-filename="netfox-banner.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xml:space="preserve"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview1"
pagecolor="#404040"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="2.8284272"
inkscape:cx="125.1579"
inkscape:cy="44.901279"
inkscape:window-width="2560"
inkscape:window-height="1048"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" /><defs
id="defs1" /><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"><g
id="g1-5"
transform="matrix(0.26458333,0,0,0.26458333,-190.26843,-380.00464)"
style="fill:#ffffff;fill-opacity:1"><text
id="netfox"
class="cls-1"
x="851.6261"
y="1516.0247"
style="font-weight:300;font-size:42.6667px;font-family:Oxanium;fill:#ffffff;fill-opacity:1">net<tspan
class="cls-2"
id="tspan1"
style="font-size:42.6667px;fill:#ffffff;fill-opacity:1">fox</tspan></text></g><g
id="g2"
transform="translate(-3.439584,27.781252)"><g
inkscape:label="Layer 1"
id="layer1-5"
transform="translate(3.4395835,-27.781249)"><path
id="rect1-3"
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.80983;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
d="M 0.3180025,0.21523374 V 16.933327 l 14.0952875,14.095287 2.524119,-2.524627 2.524118,2.524627 14.144631,-14.144631 h -8.285032 l 2.524119,2.52412 -2.524119,2.524627 -2.524118,-2.524627 2.524118,-2.52412 H 16.986751 L 25.321127,0.21523374 H 16.986752 L 8.6523775,16.883984 11.176497,19.408103 8.6523776,21.932731 6.1282584,19.408103 8.6523775,16.883984 Z m 25.0031245,0 8.318096,16.63568426 0.01628,-0.01628 z" /><path
style="fill:none;stroke:#ffffff;stroke-width:0.521026;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:6;stroke-opacity:1"
d="M 16.88398,33.606161 0.2605135,16.982693 V 0.26051574 H 25.269743 L 33.606158,16.883983 Z"
id="path3" /></g></g></g></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

+101
View File
@@ -0,0 +1,101 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="272"
height="128"
viewBox="0 0 71.966666 33.866666"
version="1.1"
id="svg1"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
sodipodi:docname="netfox-banner-hor.svg"
inkscape:export-filename="netfox-banner.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#404040"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="2.8284272"
inkscape:cx="118.08683"
inkscape:cy="30.759144"
inkscape:window-width="2560"
inkscape:window-height="1048"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<g
id="g1">
<path
id="rect1"
style="fill:#ff8100;fill-opacity:1;stroke:none;stroke-width:0.822685;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
d="m 0,0 12.009119,12.009119 h 23.87646 v -24.018238 l -17.92507,-5.95139 z"
transform="rotate(45)"
sodipodi:nodetypes="cccccc" />
<path
d="M 16.93334,0 8.4666699,16.93334 0,0 Z"
fill="#5fff97"
id="path1"
style="stroke-width:4.23333" />
<path
d="M 33.866666,16.93334 25.399996,0 16.93334,16.93334 Z"
fill="#ff5f5f"
id="path2"
style="stroke-width:4.23333" />
<rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.529167;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
id="rect2"
width="3.6265092"
height="3.6265092"
x="17.96052"
y="5.9868398"
transform="rotate(45)" />
<rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.529167;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
id="rect3"
width="3.6265092"
height="3.6265092"
x="29.934195"
y="-5.9868345"
transform="rotate(45)" />
<rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.529167;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
id="rect4"
width="3.6265092"
height="3.6265092"
x="32.259071"
y="8.3826094"
transform="rotate(45)" />
</g>
<g
id="g1-5"
transform="matrix(0.26458333,0,0,0.26458333,-190.26843,-380.00464)">
<text
id="netfox"
class="cls-1"
x="851.6261"
y="1516.0247"
style="font-weight:300;font-size:42.6667px;font-family:Oxanium;fill:#ff8100">net<tspan
class="cls-2"
id="tspan1"
style="font-size:42.6667px;fill:#ffffff;fill-opacity:1">fox</tspan></text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="256"
height="256"
viewBox="0 0 256 256"
version="1.1"
id="svg1"
sodipodi:docname="netfox-banner-ver-alt.svg"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
xml:space="preserve"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:x="adobe:ns:meta/"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><sodipodi:namedview
id="namedview1"
pagecolor="#bfbfbf"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="3.3046876"
inkscape:cx="102.27896"
inkscape:cy="132.8416"
inkscape:window-width="2560"
inkscape:window-height="1048"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" /><metadata
id="metadata1"><?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?><x:xmpmeta
x:xmptk="Adobe XMP Core 5.6-c142 79.160924, 2017/07/13-01:06:39 "><rdf:RDF><rdf:Description
rdf:about="" /></rdf:RDF></x:xmpmeta><?xpacket end="w"?></metadata><defs
id="defs1"><style
id="style1">
.cls-1 {
font-size: 250px;
fill: #ff8100;
font-family: Oxanium;
font-weight: 300;
}
.cls-2 {
fill: #fff;
}
</style></defs><text
id="netfox"
class="cls-1"
x="62.911915"
y="211.78668"
style="font-size:42.6667px">net<tspan
style="font-size:42.6667px;fill:#000000;fill-opacity:1"
id="tspan2">fox</tspan></text><g
inkscape:label="Layer 1"
id="layer1"
transform="matrix(3.7795276,0,0,3.7795276,64,44.213318)"><path
id="rect1"
style="fill:#ff8100;fill-opacity:1;stroke:none;stroke-width:0.822685;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
d="m 0,0 12.009119,12.009119 h 23.87646 v -24.018238 l -17.92507,-5.95139 z"
transform="rotate(45)"
sodipodi:nodetypes="cccccc" /><path
d="M 16.93334,0 8.4666699,16.93334 0,0 Z"
fill="#5fff97"
id="path1"
style="stroke-width:4.23333" /><path
d="M 33.866666,16.93334 25.399996,0 16.93334,16.93334 Z"
fill="#ff5f5f"
id="path2"
style="stroke-width:4.23333" /><rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.529167;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
id="rect2"
width="3.6265092"
height="3.6265092"
x="17.96052"
y="5.9868398"
transform="rotate(45)" /><rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.529167;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
id="rect3"
width="3.6265092"
height="3.6265092"
x="29.934195"
y="-5.9868345"
transform="rotate(45)" /><rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.529167;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
id="rect4"
width="3.6265092"
height="3.6265092"
x="32.259071"
y="8.3826094"
transform="rotate(45)" /></g></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="256"
height="256"
viewBox="0 0 256 256"
version="1.1"
id="svg1"
sodipodi:docname="netfox-banner-ver-black.svg"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
xml:space="preserve"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:x="adobe:ns:meta/"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><sodipodi:namedview
id="namedview1"
pagecolor="#bfbfbf"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="2.336767"
inkscape:cx="93.291287"
inkscape:cy="138.22516"
inkscape:window-width="2560"
inkscape:window-height="1048"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1-6" /><metadata
id="metadata1"><?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?><x:xmpmeta
x:xmptk="Adobe XMP Core 5.6-c142 79.160924, 2017/07/13-01:06:39 "><rdf:RDF><rdf:Description
rdf:about="" /></rdf:RDF></x:xmpmeta><?xpacket end="w"?></metadata><defs
id="defs1"><style
id="style1">
.cls-1 {
font-size: 250px;
fill: #ff8100;
font-family: Oxanium;
font-weight: 300;
}
.cls-2 {
fill: #fff;
}
</style></defs><text
id="netfox"
class="cls-1"
x="62.911915"
y="211.78668"
style="font-size:42.6667px;fill:#000000;fill-opacity:1">net<tspan
style="font-size:42.6667px;fill:#000000;fill-opacity:1"
id="tspan2">fox</tspan></text><g
inkscape:label="Layer 1"
id="layer1-6"
transform="matrix(3.7795276,0,0,3.7795276,63.798132,44.399864)"><path
id="rect1-2"
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.80983;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
d="M 0.3180025,0.21523374 V 16.933327 l 14.0952875,14.095287 2.524119,-2.524627 2.524118,2.524627 14.144631,-14.144631 h -8.285032 l 2.524119,2.52412 -2.524119,2.524627 -2.524118,-2.524627 2.524118,-2.52412 H 16.986751 L 25.321127,0.21523374 H 16.986752 L 8.6523775,16.883984 11.176497,19.408103 8.6523776,21.932731 6.1282584,19.408103 8.6523775,16.883984 Z m 25.0031245,0 8.318096,16.63568426 0.01628,-0.01628 z" /><path
style="fill:none;stroke:#000000;stroke-width:0.521026;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:6;stroke-opacity:1"
d="M 16.88398,33.606161 0.2605135,16.982693 V 0.26051574 H 25.269743 L 33.606158,16.883983 Z"
id="path3" /></g></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="256"
height="256"
viewBox="0 0 256 256"
version="1.1"
id="svg1"
sodipodi:docname="netfox-banner-ver-white.svg"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
xml:space="preserve"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:x="adobe:ns:meta/"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><sodipodi:namedview
id="namedview1"
pagecolor="#404040"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="2.336767"
inkscape:cx="93.291287"
inkscape:cy="138.22516"
inkscape:window-width="2560"
inkscape:window-height="1048"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" /><metadata
id="metadata1"><?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?><x:xmpmeta
x:xmptk="Adobe XMP Core 5.6-c142 79.160924, 2017/07/13-01:06:39 "><rdf:RDF><rdf:Description
rdf:about="" /></rdf:RDF></x:xmpmeta><?xpacket end="w"?></metadata><defs
id="defs1"><style
id="style1">
.cls-1 {
font-size: 250px;
fill: #ff8100;
font-family: Oxanium;
font-weight: 300;
}
.cls-2 {
fill: #fff;
}
</style></defs><text
id="netfox"
class="cls-1"
x="62.911915"
y="211.78668"
style="font-size:42.6667px;fill:#ffffff;fill-opacity:1">net<tspan
style="font-size:42.6667px;fill:#ffffff;fill-opacity:1"
id="tspan2">fox</tspan></text><g
inkscape:label="Layer 1"
id="layer1-6"
transform="matrix(3.7795276,0,0,3.7795276,63.798132,44.399864)"><path
id="rect1-2"
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.80983;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
d="M 0.3180025,0.21523374 V 16.933327 l 14.0952875,14.095287 2.524119,-2.524627 2.524118,2.524627 14.144631,-14.144631 h -8.285032 l 2.524119,2.52412 -2.524119,2.524627 -2.524118,-2.524627 2.524118,-2.52412 H 16.986751 L 25.321127,0.21523374 H 16.986752 L 8.6523775,16.883984 11.176497,19.408103 8.6523776,21.932731 6.1282584,19.408103 8.6523775,16.883984 Z m 25.0031245,0 8.318096,16.63568426 0.01628,-0.01628 z" /><path
style="fill:none;stroke:#ffffff;stroke-width:0.521026;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:6;stroke-opacity:1"
d="M 16.88398,33.606161 0.2605135,16.982693 V 0.26051574 H 25.269743 L 33.606158,16.883983 Z"
id="path3" /></g></svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="256"
height="256"
viewBox="0 0 256 256"
version="1.1"
id="svg1"
sodipodi:docname="netfox-banner-ver.svg"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
xml:space="preserve"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:x="adobe:ns:meta/"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><sodipodi:namedview
id="namedview1"
pagecolor="#404040"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="2.336767"
inkscape:cx="97.142762"
inkscape:cy="139.50899"
inkscape:window-width="2560"
inkscape:window-height="1048"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" /><metadata
id="metadata1"><?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?><x:xmpmeta
x:xmptk="Adobe XMP Core 5.6-c142 79.160924, 2017/07/13-01:06:39 "><rdf:RDF><rdf:Description
rdf:about="" /></rdf:RDF></x:xmpmeta><?xpacket end="w"?></metadata><defs
id="defs1"><style
id="style1">
.cls-1 {
font-size: 250px;
fill: #ff8100;
font-family: Oxanium;
font-weight: 300;
}
.cls-2 {
fill: #fff;
}
</style></defs><text
id="netfox"
class="cls-1"
x="62.911915"
y="211.78668"
style="font-size:42.6667px">net<tspan
style="font-size:42.6667px;fill:#ffffff;fill-opacity:1"
id="tspan2">fox</tspan></text><g
inkscape:label="Layer 1"
id="layer1"
transform="matrix(3.7795276,0,0,3.7795276,64,44.213318)"><path
id="rect1"
style="fill:#ff8100;fill-opacity:1;stroke:none;stroke-width:0.822685;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
d="m 0,0 12.009119,12.009119 h 23.87646 v -24.018238 l -17.92507,-5.95139 z"
transform="rotate(45)"
sodipodi:nodetypes="cccccc" /><path
d="M 16.93334,0 8.4666699,16.93334 0,0 Z"
fill="#5fff97"
id="path1"
style="stroke-width:4.23333" /><path
d="M 33.866666,16.93334 25.399996,0 16.93334,16.93334 Z"
fill="#ff5f5f"
id="path2"
style="stroke-width:4.23333" /><rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.529167;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
id="rect2"
width="3.6265092"
height="3.6265092"
x="17.96052"
y="5.9868398"
transform="rotate(45)" /><rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.529167;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
id="rect3"
width="3.6265092"
height="3.6265092"
x="29.934195"
y="-5.9868345"
transform="rotate(45)" /><rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.529167;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
id="rect4"
width="3.6265092"
height="3.6265092"
x="32.259071"
y="8.3826094"
transform="rotate(45)" /></g></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="128"
height="128"
viewBox="0 0 33.866666 33.866666"
version="1.1"
id="svg1"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
sodipodi:docname="netfox-icon-black.svg"
inkscape:export-filename="netfox.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#bfbfbf"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="1"
inkscape:cx="-262"
inkscape:cy="90"
inkscape:window-width="2560"
inkscape:window-height="1048"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
id="rect1"
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.80983;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
d="M 0.3180025,0.21523374 V 16.933327 l 14.0952875,14.095287 2.524119,-2.524627 2.524118,2.524627 14.144631,-14.144631 h -8.285032 l 2.524119,2.52412 -2.524119,2.524627 -2.524118,-2.524627 2.524118,-2.52412 H 16.986751 L 25.321127,0.21523374 H 16.986752 L 8.6523775,16.883984 11.176497,19.408103 8.6523776,21.932731 6.1282584,19.408103 8.6523775,16.883984 Z m 25.0031245,0 8.318096,16.63568426 0.01628,-0.01628 z" />
<path
style="fill:none;stroke:#000000;stroke-width:0.521026;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:6;stroke-opacity:1"
d="M 16.88398,33.606161 0.2605135,16.982693 V 0.26051574 H 25.269743 L 33.606158,16.883983 Z"
id="path3" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="128"
height="128"
viewBox="0 0 33.866666 33.866666"
version="1.1"
id="svg1"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
sodipodi:docname="netfox-icon-white.svg"
inkscape:export-filename="netfox.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#404040"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="4"
inkscape:cx="72.75"
inkscape:cy="77"
inkscape:window-width="2560"
inkscape:window-height="1048"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
id="rect1"
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.80983;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
d="M 0.3180025,0.21523374 V 16.933327 l 14.0952875,14.095287 2.524119,-2.524627 2.524118,2.524627 14.144631,-14.144631 h -8.285032 l 2.524119,2.52412 -2.524119,2.524627 -2.524118,-2.524627 2.524118,-2.52412 H 16.986751 L 25.321127,0.21523374 H 16.986752 L 8.6523775,16.883984 11.176497,19.408103 8.6523776,21.932731 6.1282584,19.408103 8.6523775,16.883984 Z m 25.0031245,0 8.318096,16.63568426 0.01628,-0.01628 z" />
<path
style="fill:none;stroke:#ffffff;stroke-width:0.521026;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:6;stroke-opacity:1"
d="M 16.88398,33.606161 0.2605135,16.982693 V 0.26051574 H 25.269743 L 33.606158,16.883983 Z"
id="path3" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

+85
View File
@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="128"
height="128"
viewBox="0 0 33.866666 33.866666"
version="1.1"
id="svg1"
inkscape:version="1.3 (0e150ed6c4, 2023-07-21)"
sodipodi:docname="netfox.svg"
inkscape:export-filename="netfox.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
inkscape:zoom="1"
inkscape:cx="-30"
inkscape:cy="-3.5"
inkscape:window-width="2560"
inkscape:window-height="1048"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="layer1" />
<defs
id="defs1" />
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1">
<path
id="rect1"
style="fill:#ff8100;fill-opacity:1;stroke:none;stroke-width:0.822685;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
d="m 0,0 12.009119,12.009119 23.87646,0 0,-24.018238 -17.92507,-5.95139 z"
transform="rotate(45)"
sodipodi:nodetypes="cccccc" />
<path
d="M 16.93334,0 8.4666699,16.93334 0,0 Z"
fill="#5fff97"
id="path1"
style="stroke-width:4.23333" />
<path
d="M 33.866666,16.93334 25.399996,0 16.93334,16.93334 Z"
fill="#ff5f5f"
id="path2"
style="stroke-width:4.23333" />
<rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.529167;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
id="rect2"
width="3.6265092"
height="3.6265092"
x="17.96052"
y="5.9868398"
transform="rotate(45)" />
<rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.529167;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
id="rect3"
width="3.6265092"
height="3.6265092"
x="29.934195"
y="-5.9868345"
transform="rotate(45)" />
<rect
style="fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.529167;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:0.33"
id="rect4"
width="3.6265092"
height="3.6265092"
x="32.259071"
y="8.3826094"
transform="rotate(45)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

+29
View File
@@ -0,0 +1,29 @@
# Contributors
Over time, *netfox* has received multiple important contributions from its
community. Huge kudos to all of our contributors!
## Special thanks
We'd like to highlight some of our contributors, in no particular order:
* [albertok](https://github.com/albertok)
* For being one of the first active members of the community
* For contributing multiple features to netfox, including window tiling
* [DustieDog](https://github.com/DustieDog)
* For creating [NetfoxSharp](https://github.com/CyFurStudios/NetfoxSharp)
* For contributing to netfox's code quality both with PRs and design
discussions
* [krazyjakee](https://github.com/krazyjakee)
* For contributing the rollback-aware state machine and FPS example
* [TheYellowArchitect](https://github.com/TheYellowArchitect)
* For suggesting and contributing to multiple important features such as
diff states and input delay
## All contributors
<a href="https://github.com/foxssake/netfox/graphs/contributors">
<img src="https://contrib.rocks/image?repo=foxssake/netfox" />
</a>
Image generated by [contrib.rocks](https://contrib.rocks).
+4
View File
@@ -0,0 +1,4 @@
span.twemoji {
display: inline-block;
width: 1em;
}
+7
View File
@@ -0,0 +1,7 @@
#version-selector {
display: inline !important;
border-radius: 2em;
padding: 0.25em 0.5em !important;
font-family: Lato,proxima-nova,Helvetica Neue,Arial,sans-serif;
}
+31
View File
@@ -0,0 +1,31 @@
{% extends "base.html" %}
{%- block extrahead %}
<script src="https://giscus.app/client.js"
data-repo="foxssake/netfox"
data-repo-id="R_kgDOKBON2A"
data-category="Docs"
data-category-id="DIC_kwDOKBON2M4Clib_"
data-mapping="title"
data-strict="0"
data-reactions-enabled="1"
data-emit-metadata="0"
data-input-position="top"
data-theme="light"
data-lang="en"
data-loading="lazy"
crossorigin="anonymous"
async>
</script>
{% endblock %}
{%- block footer %}
<hr>
<h2>User discussion</h2>
<div class="giscus" ></div>
<hr>
{%- if config.copyright %}
<p>{{ config.copyright }}</p>
{%- endif %}
<p>{% trans mkdocs_link='<a href="https://www.mkdocs.org/">MkDocs</a>' %}Documentation built with {{ mkdocs_link }}.{% endtrans %}</p>
{%- endblock %}
+32
View File
@@ -0,0 +1,32 @@
# External tutorials
While these docs aim to cover everything netfox-related, we are happy to be
promoting other creators who also create content that helps understanding
netfox.
## BatteryAcidDev
In-depth tutorials explaining networking from the basics. Excellent content
both for newcomers and seasoned developers. Make sure to check the YouTube
channel as well as these videos!
- [Godot Multiplayer Lag Compensation with Netfox](https://youtu.be/GqHTNmRspjU?si=--fKtAOvJiYd5RTd)
- [Start Your Multiplayer Game with this Template](https://youtu.be/AplKwFtBMjU?si=LBwW4X5D_A4gL33U)
- [Complete Game Walkthrough](https://youtu.be/tWLZNCJISYU?si=1ewd1fgd5vx-3HI1)
- [Run Your Own P2P Server with Noray!](https://youtu.be/9pEBq6hLCzQ?si=OlyK-NXyxsnJ2A8A)
[:simple-youtube: YouTube](https://www.youtube.com/@BatteryAcidDev/) |
[:simple-discord: Discord](https://discord.gg/psjbBDvNBK)
## Your content here!
Are you making a video about netfox? A tutorial series? A blog post? Something
else?
Let us know, we'd love to feature it! Reach out on [Github discussions], on
[Discord], or in the comments at the bottom of this page.
[Github discussions]: https://github.com/foxssake/netfox/discussions/
[Discord]: https://discord.gg/xWGh4GskG5
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+119
View File
@@ -0,0 +1,119 @@
![netfox banner](./assets/press-kit/netfox-banner-hor-alt.svg.preview.png)
[![License](https://img.shields.io/github/license/foxssake/netfox)](https://github.com/foxssake/netfox/blob/main/LICENSE)
[![GitHub Release](https://img.shields.io/github/v/release/foxssake/netfox)](https://github.com/foxssake/netfox/releases)
[![Documentation](https://img.shields.io/badge/Docs-github.io-blue)](https://foxssake.github.io/netfox/)
[![Discord](https://img.shields.io/discord/1253434107656933447?logo=discord&label=Discord)](https://discord.gg/xWGh4GskG5)
[![ko-fi](https://img.shields.io/badge/Support%20on-ko--fi-ff5e5b?logo=ko-fi)](https://ko-fi.com/T6T8WZD0W)
A set of addons for responsive online games with the [Godot engine].
## Features
* ⏲️ Consistent timing across multiple machines
* 🖥️ Supports client-server architecture
* 🧈 Smooth motion with easy-to-use interpolation
* 💨 Lag compensation with Client-side Prediction and Server-side Reconciliation
* 🛜 Bullet-proof connectivity with [noray] integration
## Overview
The package consists of multiple addons, each with different features:
* [netfox]
* The core package, implements timing, rollback and other multiplayer
features
* *Start here*
* [netfox.noray]
* Implements [noray] integration to establish connection between players
* *Useful for online games*
* [netfox.extras]
* Provides high-level, game-specific, convenience features built on top of
netfox, like base classes for input management or weapons
* *Check for reusable components for your game*
* [netfox.internals]
* Shared utilities for the other addons
* Included as dependency, no need to install separately
## C# Support
For experimental C# support, see the [Netfox Sharp guide](netfox/guides/netfox-sharp.md).
## Install
### Releases
Find the latest netfox under
[Releases](https://github.com/foxssake/netfox/releases)
Each release contains the addons, and a build of [Forest Brawl] for Windows and
Linux. Each addon has its dependencies packed with it - e.g.
*"netfox.extras.vx.y.z.zip"* also contains both *netfox* and
*netfox.internals*.
> Note: For releases before v1.1.1, a separate *".with-deps.zip"* version
> contains the addon and its dependencies, while the regular zips contain only
> the addon itself.
### Asset Library
Search for the addon name in Godot's AssetLib or download from the site:
* [netfox](https://godotengine.org/asset-library/asset/2375)
* [netfox.noray](https://godotengine.org/asset-library/asset/2376)
* [netfox.extras](https://godotengine.org/asset-library/asset/2377)
### Source
Download the [source] and copy the addons of your choice to your Godot project.
### Enable the addons
After adding *netfox* to your project, make sure to enable the addons in your
project settings. Otherwise, Godot will present you with errors about
undeclared identifiers.
## Supported versions
Godot 4.x is supported by netfox. If you find any issue using any supported
version, please [open an issue].
Forest Brawl and the other examples are written against Godot 4.1. They may or
may not work when opened with any other version.
## Prototyping
To try your game online with [noray], a free to use instance is hosted at
`tomfol.io:8890`, the same instance used by [Forest Brawl].
You can use this [noray] instance to quickly test your games online, but is not
recommended for shipping games. The instance has configured limits, and no
uptime guarantees are made.
## About this documentation
These pages assume that you are familiar with both Godot, its multiplayer
capabilities, and building multiplayer games in general. Missing any of these
might make your reading experience more difficult than preferred.
Some links to get you up to speed:
* [Godot Engine docs](https://docs.godotengine.org/en/stable/index.html)
* [Godot Engine High-level multiplayer](https://docs.godotengine.org/en/stable/tutorials/networking/high_level_multiplayer.html)
* [Networking for Physics Programmers](https://www.gdcvault.com/play/1022195/Physics-for-Game-Programmers-Networking)
## About the tutorials
The tutorials are intended to get you started fast, but don't explain much of
how things work. For that, refer to the guides.
[netfox]: https://github.com/foxssake/netfox/tree/main/addons/netfox
[netfox.noray]: https://github.com/foxssake/netfox/tree/main/addons/netfox.noray
[netfox.extras]: https://github.com/foxssake/netfox/tree/main/addons/netfox.extras
[netfox.internals]: https://github.com/foxssake/netfox/tree/main/addons/netfox.internals
[Forest Brawl]: https://github.com/foxssake/netfox/tree/main/examples/forest-brawl
[open an issue]: https://github.com/foxssake/netfox/issues
[Godot engine]: https://godotengine.org/
[noray]: https://github.com/foxssake/noray
[source]: https://github.com/foxssake/netfox/archive/refs/heads/main.zip
+78
View File
@@ -0,0 +1,78 @@
# Made with netfox
## Bubble Battle
<a href="https://store.steampowered.com/app/3180520/Bubble_Battle/" markdown>
![Bubble Battle](./assets/showcase/bubble-battle.jpg)
</a>
Bubble Battle is a retro online/local multiplayer fighting game inspired by
arcade classics. Players fly around with one simple objective: burst opponents
bubbles before they burst yours. Collect powerups and out-maneuver opponents
for fast-paced, addictive fun!
[:simple-steam: Steam](https://store.steampowered.com/app/3180520/Bubble_Battle/)
## PL4no-B
<a href="https://store.steampowered.com/app/3423700/PL4noB/" markdown>
![PL4no-B](./assets/showcase/pl4nob.jpg)
</a>
The best and most modern 2D tactical shooter, inspired by Counter-Strike and
Hotline Miami. Play online with friends or meet new ones in diverse game modes.
Unlock a huge variety of guns and utilities, earn points to build your base,
unlock features, and invade other players bases for endless fun!
[:simple-steam: Steam](https://store.steampowered.com/app/3423700/PL4noB/)
## Chrome Carnage
<a href="https://store.steampowered.com/app/3204000/Chrome_Carnage/">
![Chrome Carnage](./assets/showcase/chrome-carnage.jpg)
</a>
Machines took all jobs but one - urban blood sport. Chrome Carnage is a
multiplayer top down shooter set in vibrant chaotic cyberpunk cities.
*Chrome Carnage* is a top-down shooter drenched in the glow of cyberpunk
dystopia, where hope is scarcer than a working vending machine. Rising sea
levels have forced humanity into overcrowded platform cities.
With everyone on basic welfare a mysterious underground app has the desperate
masses hooked: win brutal street tournaments, score hectic amounts of crypto,
and maybe—just maybe—eat something that doesnt squeak.
[:simple-steam: Steam](https://store.steampowered.com/app/3204000/Chrome_Carnage/)
## Nitro Turtles
<a href="https://store.steampowered.com/app/3952070/Nitro_Turtles/" markdown>
![Nitro Turtles](./assets/showcase/nitro-turtles.jpg)
</a>
Nitro Turtles is a racing game about super fast turtles! Reach insane speeds
with awesome shell-attached gadgets, compete in crazy races with friends, and
sprint across the finish line to claim ultimate turtle victory! To be the
fastest turtle around, you'll need to master shell gadget usage, learn every
shortcut imaginable, and maybe even consider using performance-enhancing
seaweed\*.
!!!note
Performance-enhancing seaweed has not been tested for safety and likely has
numerous side effects
[:simple-steam: Steam](https://store.steampowered.com/app/3952070/Nitro_Turtles/)
## Your game here!
Building something cool with *netfox*? Whether it's a game, an app, tool, or
anything else, we'd be happy to showcase it!
Reach out on [Github discussions], on [Discord], or in the comments at the
bottom of this page.
[Github discussions]: https://github.com/foxssake/netfox/discussions/207
[Discord]: https://discord.gg/xWGh4GskG5
-104
View File
@@ -1,104 +0,0 @@
# Tactical Shooter — Mapmaking SDK
Everything you need to build, validate, package, and ship competitive maps for
Tactical Shooter. This SDK is designed for the community — no C++, no GDextension
tinkering required. You only need Godot 4 (any 4.x) and a text editor.
## Quick Start
```bash
# Copy the template project
cp -r client/map_template/ maps/my_first_map/
# Rename the demo scene
mv maps/my_first_map/template_map.tscn maps/my_first_map/de_my_map.tscn
# Open in Godot
godot maps/my_first_map/project.godot
```
Then read the [Getting Started guide](01-getting-started.md).
## What You Can Build
- **Competitive defuse maps** — A-site, B-site, CT/T spawns, buy zones
- **Custom game modes** — The template is gameplay-agnostic; your map logic
is in Godot groups, not hardcoded
- **Community servers** — Ship maps as `.pck` files via the registry server
## SDK Components
| Component | Location | Purpose |
|-----------|----------|---------|
| [Template project](01-getting-started.md) | `client/map_template/` | Standalone Godot project with CSG prefabs and demo scene |
| [CSG Prefabs](02-building-geometry.md) | `client/map_template/assets/prefabs/` | 6 drop-in node types (spawns, sites, zones) |
| [Editor Validator](04-validation.md) | `client/map_template/template_map.gd` | Auto-runs when scene opened in editor |
| [Headless Validator](04-validation.md) | `client/tools/validate_map.gd` | 4-module CLI validator for CI/CD |
| [Packaging](05-packaging-and-shipping.md) | `scripts/map_packaging/pack_map.gd` | Exports `.tscn``.pck` addon pack |
| [Registry Server](05-packaging-and-shipping.md) | `scripts/map_packaging/map_registry_server.py` | Serves `.pck` files over HTTP |
| [Client Downloader](05-packaging-and-shipping.md) | `client/scripts/map_downloader.gd` | Autoload that downloads + loads `.pck` at runtime |
## Map Authoring Workflow
```
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ 1. Build │ ──→ │ 2. Validate │ ──→ │ 3. Package │
│ - CSG geometry │ │ - Editor check │ │ - pack_map.gd │
│ - Prefab nodes │ │ - CLI validator │ │ - → .pck file │
│ - Lighting │ │ - Fix warnings │ │ │
└──────────────────┘ └──────────────────┘ └──────────────────┘
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ 6. Load & Play │ ←── │ 5. Download │ ←── │ 4. Upload │
│ - Auto-download │ │ - Registry HTTP │ │ - map_registry │
│ - changelevel │ │ - Cache to disk │ │ - .pck + .json │
└──────────────────┘ └──────────────────┘ └──────────────────┘
```
## Performance Targets
Maps built with the SDK must meet these budgets:
| Metric | Target | Enforced by |
|------------------|-------------|--------------------------------|
| Triangle count | ≤ 50,000 | `validate_polycount.gd` |
| Texture size | ≤ 1024×1024 | `validate_textures.gd` |
| Dynamic lights | ≤ 4 | `validate_lights.gd` |
| LightmapGI | ✓ Baked | `validate_lights.gd` |
| ReflectionProbe | ≥ 1 | `validate_lights.gd` (warning) |
| FPS target | 60+ | Project rendering settings |
## File Structure Reference
```
tactical-shooter/
├── client/
│ ├── project.godot # Main game project
│ ├── map_template/ # ← YOU ARE HERE (template project)
│ │ ├── project.godot # Standalone Godot project
│ │ ├── template_map.tscn # Demo scene (rename me)
│ │ ├── template_map.gd # Editor validation script
│ │ └── assets/prefabs/ # CSG prefab nodes
│ ├── tools/
│ │ ├── validate_map.gd # CLI validator entry point
│ │ └── validate_map/ # 4 validator modules
│ │ ├── validate_scene.gd
│ │ ├── validate_polycount.gd
│ │ ├── validate_textures.gd
│ │ └── validate_lights.gd
│ └── scripts/
│ └── map_downloader.gd # Client autoload for .pck loading
├── scripts/
│ └── map_packaging/
│ ├── pack_map.gd # Godot editor → .pck exporter
│ └── map_registry_server.py # Python HTTP registry server
└── docs/mapmaking/
├── 00-index.md # This document
├── 01-getting-started.md # Template project setup
├── 02-building-geometry.md # CSG & prefab guide
├── 03-lighting-and-env.md # Lighting & baking
├── 04-validation.md # Validator usage
├── 05-packaging-and-shipping.md# Publishing pipeline
└── 06-faq-and-troubleshooting.md
```
-135
View File
@@ -1,135 +0,0 @@
# Getting Started — Your First Map
This guide walks you through setting up the map template project and creating
a basic playable map.
## Prerequisites
- Godot 4.0 or later (any 4.x stable)
- The Tactical Shooter repository cloned
- Familiarity with Godot's editor (scene tree, inspector, 3D viewport)
## 1. Copy the Template
The template project lives at `client/map_template/`. Copy it somewhere outside
the main project (or keep it adjacent — it's a standalone Godot project):
```bash
# From the tactical-shooter repository root
cp -r client/map_template/ maps/my_first_map/
cd maps/my_first_map/
mv template_map.tscn de_dust2.tscn
```
## 2. Open in Godot
Launch Godot and click **Import** (not Open). Navigate to your copied directory
and select `project.godot`. The map template project will open.
> **Why "Import"?** Because template is a standalone project with its own
> `project.godot`. You're importing a new project into the Godot project list,
> not opening a scene within an existing project.
## 3. Explore the Demo Scene
Open `de_dust2.tscn` (the renamed `template_map.tscn`). You'll see:
- **Floor**: A green CSGBox3D pad (16×20 units)
- **Walls**: Orange CSGBox3D walls forming a rough box
- **Spawn pads**: Green (CT) and red (T) Marker3D spawn points
- **Bomb sites**: Semi-transparent orange floor panels
- **Buy zones**: Semi-transparent yellow trigger areas
- **Lighting**: DirectionalLight3D (sun), OmniLight3D fill, LightmapGI
- **Environment**: WorldEnvironment with sky
The scene also contains a `template_map.gd` editor tool script. When you open
this scene, the **Output** panel (bottom dock) shows auto-validation results
for the map structure.
## 4. Build Your First Room
1. **Select the floor** — Click `Floor` in the scene tree
2. **Scale it** — With the scale tool (R), drag the red/green/blue handles
to resize the floor to roughly 12×16 units
3. **Add walls** — Select `Walls` → right-click → **Instantiate Child Scene**
select `assets/prefabs/map_bounds.tscn`. Size them to enclose the floor
4. **Add a ceiling** — Duplicate (Ctrl+D) the floor, move it up 4 units,
set **Operation** to Union (0) so it acts as a solid ceiling
> **Tip:** Use **View → Perspective** and rotate around to check your geometry
> from all angles. Walls should overlap slightly to prevent light leaks.
## 5. Place Spawn Points
Spawn points tell the game where players appear at round start.
**CT Spawns:**
- Drag `assets/prefabs/ct_spawn.tscn` from the FileSystem dock into the scene
- Position it on the floor at one end of the map
- The green pad's +Z arrow shows the direction players face on spawn
- Duplicate for 5 players (competitive standard)
**T Spawns:**
- Same process with `assets/prefabs/t_spawn.tscn` (red pads)
- Place at the opposite end of the map
## 6. Define Bomb Sites
A competitive defuse map needs two bomb sites, A and B.
1. Drag `assets/prefabs/bomb_site.tscn` into the scene
2. Rename it to `BombsiteA`
3. Resize the CollisionShape3D to cover the intended site area
4. In the **Node** dock → **Groups** tab, ensure it has:
- `bomb_site` (required — game logic finds sites by this group)
- `bombsite_a` (informational — helps identify which site)
5. Repeat for BombsiteB with group `bombsite_b`
## 7. Add Buy Zones
Buy zones mark areas where players can purchase equipment.
1. Drag `assets/prefabs/buy_zone.tscn` into the scene
2. Place one at the CT spawn area (group `buy_zone` stays)
3. Resize the yellow CollisionShape3D to cover the spawn room
4. Duplicate for T spawn — add subgroup `ct_buy` or `t_buy` on each
## 8. Check the Editor Validator
Open the **Output** (bottom dock). The `template_map.gd` script runs
automatically. You should see something like:
```
=== Map Template: Validate Scene ===
+ CT Spawn points (5 found)
+ T Spawn points (5 found)
+ Buy zones (2 found)
+ Bomb sites (2 found)
+ Cubemap capture origins (1 found)
+ Map boundary walls (8 found)
+ CSG Floor geometry (1 nodes)
+ CSG Wall geometry (4 walls)
+ LightmapGI configured ✓
+ LightmapGI: NOT YET BAKED
+ ReflectionProbe present
+ WorldEnvironment present
+ DirectionalLight3D (sun) present
```
The "NOT YET BAKED" warning is expected — you'll bake lighting after geometry
is finalised (see [Lighting guide](03-lighting-and-env.md)).
## 9. Save and Package
When your map is ready for testing:
1. Save the scene (`Ctrl+S`)
2. Read the [Validation guide](04-validation.md) for headless CI checks
3. Read the [Packaging guide](05-packaging-and-shipping.md) to ship it
## Next Steps
- [Building Geometry with CSG](02-building-geometry.md) — detailed prefab reference
- [Lighting & Baking](03-lighting-and-env.md) — how to light your map properly
- [Validator Reference](04-validation.md) — CLI validator exit codes and CI
- [Packaging & Shipping](05-packaging-and-shipping.md) — publishing as .pck
-245
View File
@@ -1,245 +0,0 @@
# Building Geometry with CSG
Tactical Shooter maps are built with Godot's **CSG** (Constructive Solid
Geometry) system — the same tool you'd use in a 3D modeller, but inside the
Godot editor. CSG nodes let you cut, join, and extrude geometry in real-time
with instant visual feedback.
## CSG Basics
### CSG Node Types
| Node | Use | Shape |
|------|-----|-------|
| `CSGBox3D` | Walls, floors, ceilings, pillars | Rectangular prism |
| `CSGSphere3D` | Domes, curved edges | Sphere |
| `CSGCylinder3D` | Columns, pipes | Cylinder |
| `CSGCombiner3D` | Group container, non-collidable parent | — |
| `CSGPolygon3D` | Extruded 2D path | Custom shape |
### CSG Operations
Each CSG node has an **Operation** property that determines how it combines
with sibling CSG nodes in the same parent:
| Value | Operation | Effect |
|-------|-----------|--------|
| 0 | **Union** | Adds to existing geometry (default) |
| 1 | **Subtraction** | Cuts a hole through parent |
| 2 | **Intersection** | Keeps only overlapping volume |
**Example — a window cutout:**
```
Wall (CSGBox3D, Union)
└── WindowCutout (CSGBox3D, Subtraction)
```
This subtracts the window shape from the wall. Move/resize the subtraction
child to reposition the window.
### Key Properties
- **`use_collision`** (bool) — Must be `true` on all floor/wall/ceiling CSG
nodes so players and bullets collide with them
- **`use_shadows`** (bool) — Enable shadow-casting on geometry
- **`material`** — Assign placeholder materials during construction; final
materials come from the main game project library
- **`snap_mode`** — Controls vertex snapping (useful for aligning geometry)
## Building a Room
A basic room needs 3 CSG layers:
```
Room (CSGCombiner3D)
├── Floor (CSGBox3D, Union, use_collision=true)
├── Walls (CSGCombiner3D)
│ ├── Wall_North (CSGBox3D, Union)
│ ├── Wall_South (CSGBox3D, Union)
│ ├── Wall_East (CSGBox3D, Union)
│ └── Wall_West (CSGBox3D, Union)
└── Ceiling (CSGBox3D, Union, use_collision=true)
```
### CSG Building Tips
1. **Overlap slightly** — Walls should extend past the floor edge by ~0.1 units
to prevent light leaks (thin gaps let light bleed through)
2. **Keep CSG simple** — Use as few CSG nodes as possible. A wall panel is one
CSGBox3D, not 4 extruded edges
3. **Convert to Mesh when done** — Once geometry is final, select all CSG
nodes → right-click → **Convert CSG to Mesh**. This bakes them into static
MeshInstance3D nodes that perform much better
4. **Avoid curved CSG for floor/walls** — CSGSphere3D and CSGCylinder3D are
expensive. Use them sparingly
## Prefab Reference
The template provides 6 drag-and-drop prefab nodes. Each is an `assets/prefabs/*.tscn`
file that you instantiate into your scene.
### ct_spawn.tscn
| Property | Value |
|----------|-------|
| Type | `Marker3D` (green pad + arrow) |
| Group | `ct_spawn` |
| Purpose | CT team spawn position |
| Quantity | 5 (one per player) |
The +Z arrow (blue axis) shows the direction players face when they spawn.
Position the pad at floor level — make it flush with the floor surface.
**Customisation:** Duplicate instances rather than editing the prefab. Adjust
only position and rotation. The green pad is cosmetic.
### t_spawn.tscn
| Property | Value |
|----------|-------|
| Type | `Marker3D` (red pad + arrow) |
| Group | `t_spawn` |
| Purpose | Terrorist team spawn position |
| Quantity | 5 |
Same usage as `ct_spawn` but red. Place at the opposite end of the map.
### buy_zone.tscn
| Property | Value |
|----------|-------|
| Type | `Area3D` with `CollisionShape3D` |
| Group | `buy_zone` |
| Purpose | Purchase-eligible zone |
| Quantity | 12 |
The game monitors `body_entered` / `body_exited` on these areas to show/hide
the buy menu. Resize the child CollisionShape3D to match the room shape.
**Additional groups (optional):**
- `ct_buy` — Only CT team can buy in this zone
- `t_buy` — Only T team can buy in this zone
If omitted, any player inside a `buy_zone` can buy (suitable for shared mid areas).
### bomb_site.tscn
| Property | Value |
|----------|-------|
| Type | `Area3D` with `CollisionShape3D` |
| Group | `bomb_site` |
| Purpose | Bomb plant zone |
| Quantity | 2 (A and B) |
Rename instances to `BombsiteA` and `BombsiteB`. Resize the CollisionShape3D
to cover the plantable area. Visual: semi-transparent orange floor panel.
**Additional groups (strongly recommended):**
- `bombsite_a` — Identifies this as site A
- `bombsite_b` — Identifies this as site B
### cubemap_origin.tscn
| Property | Value |
|----------|-------|
| Type | `Node3D` (blue sphere marker) |
| Group | `cubemap_origin` |
| Purpose | Marks ReflectionProbe capture position |
| Quantity | 1 |
Place at eye height ≈ 1.6 units above the floor in the most visually prominent
area (usually mid-map or the central choke point). The ReflectionProbe is a
separate node that should be placed at this marker's position after geometry
is finalised.
### map_bounds.tscn
| Property | Value |
|----------|-------|
| Type | `Area3D` with `CollisionShape3D` |
| Group | `map_bounds` |
| Purpose | Out-of-bounds kill wall |
| Quantity | 48 (enclose the perimeter) |
**Usage:**
- Place one instance per perimeter face (north, south, east, west)
- Resize each CollisionShape3D to form a closed box around the playable area
- Make the floor-bound wall tall enough that players can't jump over it
- The game detects `body_exited` from these areas and teleports/kills
out-of-bounds players
> **Performance tip:** `map_bounds` nodes should be as thin as practical —
> these are just detection triggers, not visual geometry. A thickness of 0.5
> units is sufficient.
## Advanced Geometry Techniques
### Corridors and Hallways
Build a corridor as a CSGCombiner3D with union floor + walls + ceiling and
subtraction nodes for doors/windows:
```
Corridor (CSGCombiner3D)
├── Floor (CSGBox3D, Union)
├── LeftWall (CSGBox3D, Union)
│ └── Door1 (CSGBox3D, Subtraction)
├── RightWall (CSGBox3D, Union)
│ └── Window1 (CSGBox3D, Subtraction)
└── Ceiling (CSGBox3D, Union)
```
### Ramps and Stairs
Use multiple CSGBox3D nodes at increasing heights. Stack them like steps and
wrap in a CSGCombiner3D for a clean hierarchy:
```
Stairs (CSGCombiner3D)
├── Step1 (CSGBox3D, Union, pos=(0, 0, 0))
├── Step2 (CSGBox3D, Union, pos=(0, 0.5, 1))
├── Step3 (CSGBox3D, Union, pos=(0, 1.0, 2))
└── Step4 (CSGBox3D, Union, pos=(0, 1.5, 3))
```
For ramps, rotate a CSGBox3D or use CSGPolygon3D with an extruded triangle shape.
### Map Scale Reference
| Element | Size (units) | Notes |
|---------|-------------|-------|
| Player height | 1.8 | Eye level ≈ 1.6 |
| Player width | 0.5 | Collision capsule radius |
| Doorway | 1.5 × 2.5 | Wide enough for 2 players |
| Corridor | 34 wide | Comfortable movement |
| Bombsite | 8×8 minimum | Playable site area |
| Spawn room | 6×8 | 5 players need space |
| Mid area | 68 wide | Main chokepoint |
## From CSG to MeshInstance3D (Baking)
When your geometry is **final** (no more changes expected):
1. Select all CSG nodes in the scene tree
2. Right-click → **Convert CSG to Mesh**
3. This replaces CSG nodes with static `MeshInstance3D` nodes
4. The mesh instances are much more performant
**Don't convert until you're done** — CSG is editable, MeshInstance3D is not.
Keep working in CSG mode; convert only for final optimisation before packaging.
## Performance Budget
See the [Validator guide](04-validation.md) for detailed numbers, but the key
geometry rules are:
- **≤ 50,000 triangles** total across all meshes
- **≤ 5,000 triangles** per individual mesh (recommended; enforced as warning)
- Use CSG for blockout only — convert to mesh before shipping
- Avoid CSGSphere3D in visible areas; use hand-modelled meshes
## Next Steps
- [Lighting & Environment Setup](03-lighting-and-env.md) — light your map
- [Map Validation](04-validation.md) — run the validator on your scene
-159
View File
@@ -1,159 +0,0 @@
# Lighting & Environment Setup
Proper lighting is the difference between a map that looks professional and one
that looks like a grey box. Tactical Shooter uses Godot's **LightmapGI** system
for baked global illumination: light bounces are pre-calculated and stored in
lightmaps, giving you realistic lighting at zero runtime cost.
## Lighting Architecture
```
DirectionalLight3D (sun) — main directional light, shadows
└─ OmniLight3D (fill) — ambient fill lights, no shadow
└─ LightmapGI — bakes all static lighting into textures
└─ baked lightmaps (zero runtime cost)
```
## 1. WorldEnvironment
Every map needs a `WorldEnvironment` node. This controls:
- **Sky** — Background appearance (skybox or solid colour)
- **Ambient light** — Base light level for unlit surfaces
- **Tonemapping** — Colour grading and exposure
**Setup:**
1. The template already includes a `WorldEnvironment` node
2. If creating from scratch, add one via the scene tree → + button → `WorldEnvironment`
3. In the Inspector, assign an `Environment` resource (New Environment)
4. Configure:
- **Background:** Sky (for outdoor) or Clear Color (for underground maps)
- **Ambient Light:** Sky (mimics sky colour) or Color (custom ambient)
- **Tonemap:** Reinhard or Filmic (prevents blown-out highlights)
> The validator warns if WorldEnvironment is missing — add it early.
## 2. DirectionalLight3D (Sun)
The main sun light. One per map.
**Recommended settings:**
| Property | Value | Reason |
|----------|-------|--------|
| `light_bake_mode` | Static (2) | Bakes into lightmap |
| `shadow_enabled` | true | Required for competitive visibility |
| `shadow_bias` | 0.1 | Prevents shadow acne on CSG |
| `angular_distance` | 0.01 | Sharp sunlight (competitive visual clarity) |
| `range` | 50100 | Large enough to cover the map |
**Positioning:**
- Rotate the sun to create interesting shadows through windows/doorways
- Avoid straight-down lighting (makes everything look flat)
- A 45° angle works well for most maps
## 3. Fill Lights (OmniLight3D)
Fill lights illuminate areas the sun doesn't reach (interiors, tunnels).
**Rules:**
- **Maximum 4 dynamic (non-baked) lights** — enforced by the validator
- Fill lights should use `light_bake_mode = Static (2)` so they bake into
the lightmap and don't count toward the dynamic budget
- Use low intensity (0.30.5) for ambient fill
**Performance tip:** Every omni/spot light with `shadow_enabled = true` is
expensive. The validator warns on dynamic lights with shadows. Baked lights
with shadows disabled are free — use them.
## 4. LightmapGI — Baked Global Illumination
LightmapGI is the backbone of Tactical Shooter's lighting. It pre-computes
light bouncing and stores the result as textures (lightmaps).
### Configuration
| Property | Recommended | Notes |
|----------|-------------|-------|
| `quality` | 2 (High) | Higher = better GI, longer bake |
| `bounces` | 3 | Minimum 2 for realistic indirect lighting |
| `texel_scale` | 1.02.0 | Lower = higher res, longer bake |
| `max_texture_size` | 1024 | Controls lightmap atlas size |
| `use_denoiser` | true | Removes noise from baked result |
| `environment_mode` | 0 (Baked Environment) | Uses WorldEnvironment for sky lighting |
### Baking
To bake: select the `LightmapGI` node → click **Bake Lightmap** in the top
toolbar (or `Ctrl+Shift+C`).
**Baking checklist:**
- [ ] All geometry is finalised (moving geometry after baking invalidates it)
- [ ] All lights have correct bake mode (Static for baked, Dynamic for real-time)
- [ ] CSG has `use_collision = true` (non-collidable CSG may not bake properly)
- [ ] WorldEnvironment is configured with a sky or ambient colour
- [ ] Save the scene before baking
**Baking time:** Varies by map complexity. A small map (10×14 units) bakes in
3060 seconds. A full competitive map (20×20) may take 510 minutes.
### After Baking
- The LightmapGI node's `light_data` property will show a `LightmapGIData` resource
- The validator checks for `light_data != null` to confirm the map is baked
- Baked lightmaps are stored in the `.tscn` file as embedded resources
- You can rebake at any time — it overwrites the previous lightmaps
**Warning:** Opening an unbaked map on a server will default to unlit rendering.
Always bake before packaging.
## 5. ReflectionProbe
ReflectionProbes add specular reflections to shiny surfaces (metal, glass, water).
- Place one `ReflectionProbe` near the `cubemap_origin` marker
- Set `max_distance` to cover the visible area (1030 units)
- Enable `box_projection` for interior spaces (corrects reflection distortion)
- Set `intensity` to 0.50.8 (realistic reflection strength)
**Tip:** You need reflection probes even if your map has no mirrors — they make
metal and plastic materials look correct.
## 6. Lighting Performance Budget
| Metric | Limit | Enforced |
|--------|-------|----------|
| Dynamic (non-baked) lights | ≤ 4 | Validator (error) |
| Total lights (baked + dynamic) | Unlimited | — |
| LightmapGI bounces | ≥ 2 | Validator (warning) |
| ReflectionProbes | ≥ 1 | Validator (warning) |
| Lightmap max texture | ≤ 2048 | Project settings |
If you exceed the dynamic light budget, set more lights to
`light_bake_mode = Static (2)` — baked lights cost nothing at runtime.
## 7. Quick Start Checklist
- [ ] WorldEnvironment with Environment resource assigned
- [ ] DirectionalLight3D (sun) with shadows
- [ ] 13 fill OmniLight3D with Static bake mode
- [ ] LightmapGI with quality=2, bounces=3, texel_scale=1.0
- [ ] ReflectionProbe at cubemap origin
- [ ] All geometry CSG has `use_collision = true`
- [ ] Save scene → Bake LightmapGI (Ctrl+Shift+C)
- [ ] Verify `light_data` is populated (not null)
## Troubleshooting
| Problem | Likely Cause | Fix |
|---------|-------------|-----|
| Light leaks at corners | CSG gap < 0.01 units | Overlap CSG edges by 0.1 |
| Baked lighting too dark | bounces too low | Set bounces ≥ 2 |
| Lightmap seams visible | texel_scale too high | Lower texel_scale to 1.0 |
| Shadow acne (striped shadows) | shadow_bias too low | Increase shadow_bias to 0.1 |
| ReflectionProbe visible as sphere | probe_mode wrong | Set to Box or disable visibility |
| Bake fails with error | Scene not saved | Save `.tscn` before baking |
## Next Steps
- [Map Validation](04-validation.md) — run all checks on your scene
- [Packaging & Shipping](05-packaging-and-shipping.md) — publish your map
-235
View File
@@ -1,235 +0,0 @@
# Map Validation
The validator catches structural problems, performance budget violations, and
lighting issues before your map ships. Two validation modes are available:
1. **Editor validation** — runs automatically when you open a map scene
2. **Headless CLI validation** — runs from the terminal, suitable for CI/CD
## Editor Validation (template_map.gd)
Every map created from the template includes `template_map.gd`, a `@tool`
script that runs automatically when the scene opens in the Godot editor.
**How to use it:**
1. Open your map's `.tscn` file in the Godot editor
2. Look at the **Output** panel (bottom dock)
3. The script prints a validation report like:
```
=== Map Template: Validate Scene ===
+ CT Spawn points (5 found)
+ T Spawn points (5 found)
+ Buy zones (2 found)
+ Bomb sites (2 found)
+ Cubemap capture origins (1 found)
+ Map boundary walls (8 found)
+ LightmapGI configured ✓
+ LightmapGI: NOT YET BAKED
+ DirectionalLight3D (sun) present
```
**Checks performed:**
- Required gameplay groups exist (`ct_spawn`, `t_spawn`, `buy_zone`, etc.)
- CSG floor and wall geometry present
- LightmapGI configured and baked
- ReflectionProbe and WorldEnvironment present
- DirectionalLight3D (sun) present
- Playable area extents estimated
## Headless CLI Validator (validate_map.gd)
The headless validator is a 4-module CLI tool at `client/tools/validate_map.gd`.
Use it for automated checking before packaging or in CI/CD pipelines.
### Usage
```bash
# From the tactical-shooter repository root
godot --path client --script tools/validate_map.gd -- res://maps/mymap.tscn
```
> The `--` separates Godot engine arguments from user arguments.
> The scene path must be a `res://`-prefixed Godot resource path.
### Exit Codes
| Code | Meaning | Description |
|------|---------|-------------|
| 0 | **PASS** | All checks passed, no warnings |
| 1 | **WARNINGS** | Passed with non-blocking suggestions |
| 2 | **ERRORS** | Failed — must-fix items present |
### CI/CD Integration
```bash
#!/bin/bash
# Gate map merges on validation
godot --path client --script tools/validate_map.gd -- res://maps/mymap.tscn
exit_code=$?
if [ $exit_code -eq 2 ]; then
echo "❌ Map validation FAILED — fix errors before submitting"
exit 1
elif [ $exit_code -eq 1 ]; then
echo "⚠️ Map passed with warnings — review suggestions"
fi
echo "✅ Map validated successfully"
exit 0
```
### Example Output
```
═══════════════════════════════════════════
MAP VALIDATOR
Scene: res://maps/de_dust2.tscn
═══════════════════════════════════════════
── [validate_scene] ────────────────────────────
✓ Root node: World (Node3D)
✓ WorldEnvironment (WorldEnvironment)
✓ LightmapGI (LightmapGI)
✓ Group "ct_spawn" → CT_SpawnPos (5 node(s))
✓ Group "t_spawn" → T_SpawnPos (5 node(s))
✓ Group "buy_zone" → BuyZone_A (2 node(s))
✓ Group "bomb_site" → BombsiteA (2 node(s))
✓ Group "cubemap_origin" → CubemapOrigin (1 node(s))
✓ pass
── [validate_polycount] ────────────────────────
Meshes scanned: 24
Total triangles: 12,842
Budget: 50,000
✓ Within budget (25.7% of 50000)
Top meshes by triangle count:
4200 Building_01 (MeshInstance3D)
2100 Wall_Detail (MeshInstance3D)
1042 Floor_Tiles (MeshInstance3D)
✓ pass
── [validate_textures] ─────────────────────────
Textures inspected: 18
✓ All textures within 1024×1024 limit
✓ pass
── [validate_lights] ───────────────────────────
Light breakdown:
DirectionalLight3D: 1
OmniLight3D: 2
SpotLight3D: 0
Dynamic (non-baked): 1
Baked: 2
✓ Dynamic light count: 1 (limit: 4)
✓ LightmapGI baked (light_data present)
Quality: 2 (0=Low, 1=Med, 2=High, 3=Ultra)
Bounces: 3
Texel Scale: 1.0
Max Texture Size: 1024
✓ WorldEnvironment configured
ReflectionProbes: 1
✓ pass
═══════════════════════════════════════════
SUMMARY
═══════════════════════════════════════════
[PASS] validate_scene
[PASS] validate_polycount
[PASS] validate_textures
[PASS] validate_lights
Errors: 0
Warnings: 0
Result: PASS
```
## Validator Modules
### 1. Scene Structure (`validate_scene.gd`)
**What it checks:**
- Scene root is a `Node3D` type
- `WorldEnvironment` node present and named correctly
- `LightmapGI` node present and named correctly
- All 5 required groups assigned (`ct_spawn`, `t_spawn`, `buy_zone`, `bomb_site`, `cubemap_origin`)
- Node names follow PascalCase convention (no spaces)
- No duplicate required groups on different nodes
**Typical errors:**
```
✖ Missing required group: "ct_spawn"
✖ Missing WorldEnvironment — map needs a WorldEnvironment node
✖ Node name contains spaces: "My Room Section"
```
### 2. Polygon Count (`validate_polycount.gd`)
**What it checks:**
- Total triangle count across all `MeshInstance3D` nodes ≤ **50,000**
- Per-mesh triangle breakdown printed (helps identify heavy assets)
- Warns on individual meshes > **5,000** triangles
- Warns if no `MeshInstance3D` nodes found at all
**Typical errors:**
```
✖ Map exceeds 50,000 triangle budget: 68,420 total (36.8% over budget)
```
### 3. Texture Sizes (`validate_textures.gd`)
**What it checks:**
- All material textures ≤ **1024×1024**
- Checks `StandardMaterial3D`, `ORMMaterial3D`, and `ShaderMaterial` texture params
- Warns on textures > **512×512** (suggested for secondary surfaces)
- LightmapGI textures checked separately (baked textures can be larger)
**Typical errors:**
```
✖ Texture exceeds 1024×1024: floor_tiles.png (albedo) — 2048×
```
### 4. Lights & Lightmap (`validate_lights.gd`)
**What it checks:**
- Dynamic (non-baked) OmniLight3D + SpotLight3D ≤ **4**
- DirectionalLight3D counts toward dynamic budget if not baked
- LightmapGI present and baked (`light_data != null`)
- Lightmap quality, bounces, texel scale, max texture size
- WorldEnvironment assigned with Environment resource
- ReflectionProbe presence and `max_distance` coverage
- Warns on dynamic lights with shadow enabled (performance cost)
**Typical errors:**
```
✖ Too many dynamic lights: 6 (max 4)
✖ No LightmapGI node found
✖ No WorldEnvironment node found
```
## Passing the Validator
The goal is exit code 0: **PASS**, no errors, no warnings.
| Code | Action |
|------|--------|
| 0 (PASS) | Your map is ready to package |
| 1 (WARNINGS) | Fix suggestions before shipping (non-blocking) |
| 2 (ERRORS) | Fix all errors — map must pass before packaging |
> **CI gate rule:** Treat exit code 2 as a hard block. Exit code 1 is advisory
> but should be reviewed. Only exit code 0 means "ship it."
## Adding a Custom Validator Module
Advanced users can extend the validator:
1. Create `client/tools/validate_map/validate_<name>.gd` extending `RefCounted`
2. Implement `func validate(scene_root: Node, scene_path: String) -> Dictionary`
3. Return `{"pass": bool, "errors": [String], "warnings": [String]}`
4. Add a `_run_module("<name>", instance, scene_path)` call in
`validate_map.gd:_ready()`
## Next Steps
- [Packaging & Shipping](05-packaging-and-shipping.md) — ship your validated map
-264
View File
@@ -1,264 +0,0 @@
# Packaging & Shipping
Once your map is built, validated, and baked, it's time to publish it to the
world. Tactical Shooter uses the **PCK** (Godot Resource Pack) format — the map
is packaged as a `.pck` file, uploaded to a registry server, and clients
download it automatically when they join a server running that map.
## Pipeline Overview
```
┌──────────┐ ┌──────────────┐ ┌──────────────┐ ┌────────────┐
│ pack_map │ → │ .pck + .json │ → │ Map Registry │ → │ Client │
│ .gd │ │ files │ → │ Server │ → │ Downloader │
└──────────┘ └──────────────┘ └──────────────┘ └────────────┘
(editor (artifacts) (HTTP serving) (autoload)
tool)
```
## Step 1: Package the Map
The `pack_map.gd` script (at `scripts/map_packaging/pack_map.gd`) is a Godot
editor tool that exports a `.tscn` scene as a standalone `.pck` resource pack.
### Usage
From the **map template project** (not the main game project):
```bash
cd maps/my_first_map/
godot --headless --script /path/to/scripts/map_packaging/pack_map.gd \
--map=res://de_my_map.tscn
```
> Replace `/path/to/` with the actual path to the tactical-shooter repository
> root, or copy `pack_map.gd` into your map project's `scripts/` directory.
### What It Does
1. Loads the specified `.tscn` scene
2. Recursively collects all dependencies (materials, textures, meshes, etc.)
3. Exports them to `user://packed_maps/de_my_map.pck`
4. Writes a metadata file `user://packed_maps/de_my_map.json`
### Output Files
```
user://packed_maps/
├── de_my_map.pck # Binary resource pack (~110 MB for a typical map)
└── de_my_map.json # JSON metadata (machine-readable)
```
**Metadata example** (`de_my_map.json`):
```json
{
"map_name": "de_my_map",
"source_scene": "res://de_my_map.tscn",
"godot_version": {
"major": 4,
"minor": 2,
"string": "4.2"
},
"packed_at": "2026-06-25T14:30:00"
}
```
### Requirements
- The map scene must use **local resources** only (relative `res://` paths)
- External dependencies (game shared assets like weapon models, player
models) should stay in the base game — the `.pck` is **additive** content
- The `.pck` contains only the map's unique assets, not the entire game
### From the Editor
You can also run `pack_map.gd` from the Godot editor:
1. Open your map project in Godot
2. Open your map scene (`de_my_map.tscn`)
3. **Project → Tools → Pack Current Map** (if configured)
4. The script detects the active scene and exports it
## Step 2: Set Up the Map Registry Server
The registry server is a lightweight Python HTTP server
(`scripts/map_packaging/map_registry_server.py`) that:
- Serves `.pck` files for download
- Provides a JSON `/maps` endpoint listing available maps
- Auto-scans the maps directory every 5 seconds
- Computes and serves SHA-256 checksums for integrity verification
### Starting the Server
```bash
cd /path/to/tactical-shooter
# Default: port 8090, maps from ./packed_maps/
python3 scripts/map_packaging/map_registry_server.py
# Custom configuration
python3 scripts/map_packaging/map_registry_server.py \
--port 8080 \
--maps-dir /data/tactical-shooter-maps \
--verbose
```
**Environment variables:**
| Variable | Overrides | Default |
|----------|-----------|---------|
| `MAP_REGISTRY_PORT` | `--port` / `-p` | 8090 |
| `MAP_REGISTRY_MAPS` | `--maps-dir` / `-d` | `./packed_maps/` |
### API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `GET /` | — | Server info and endpoint documentation |
| `GET /maps` | — | JSON list of all available maps with metadata |
| `GET /maps/<name>.pck` | — | Download map `.pck` binary |
| `GET /maps/<name>.json` | — | Download per-map metadata |
| `OPTIONS /maps` | — | CORS preflight |
**Example responses:**
```
GET /maps
```
```json
{
"maps": [
{
"name": "de_dust2",
"size": 5432100,
"version": 1,
"description": "Classic competitive map",
"checksum_sha256": "a1b2c3d4...",
"packed_at": "2026-06-25T14:30:00"
}
],
"server_name": "Tactical Shooter Map Registry",
"server_version": "1.0.0",
"map_count": 1
}
```
```
GET /maps/de_dust2.pck
```
→ Returns the `.pck` binary file as an octet-stream download.
### Deployment Options
- **Local dev:** Run on the same machine as the game (default `127.0.0.1:8090`)
- **Server host:** Run on a public server alongside the master server
- **Docker:** Wrap in a Docker container for easy deployment
- **Systemd service:** Create a service file for production hosting
**Systemd service example:**
```ini
[Unit]
Description=Tactical Shooter Map Registry
After=network.target
[Service]
Type=simple
User=tactical
WorkingDirectory=/opt/tactical-shooter
ExecStart=/usr/bin/python3 scripts/map_packaging/map_registry_server.py \
--port 8090 --maps-dir /data/tactical-maps
Restart=on-failure
[Install]
WantedBy=multi-user.target
```
## Step 3: Upload Your Map
1. Copy the `.pck` and `.json` files to the registry server's maps directory:
```bash
cp user://packed_maps/de_my_map.pck /data/tactical-maps/
cp user://packed_maps/de_my_map.json /data/tactical-maps/
```
2. The server auto-detects new files within 5 seconds
3. Verify via `curl http://your-server:8090/maps`
## Step 4: Client Downloads
The client uses `MapDownloader` (autoload at `client/scripts/map_downloader.gd`)
to fetch and load maps at runtime.
### How It Works
1. Player joins a server that specifies a map name
2. The client checks its local cache (`user://maps/manifest.json`)
3. If the map is not cached, `MapDownloader.fetch_map_list()` polls
the registry server to verify the map exists
4. `MapDownloader.download_map(name)` downloads the `.pck` to `user://maps/`
5. `ProjectSettings.load_resource_pack()` loads it into the game
6. The server changes to the new level
### Map Caching
```
user://maps/
├── de_dust2.pck # Cached map binary
├── de_inferno.pck # Another cached map
└── manifest.json # Cache index
```
**Cache behaviour:**
- Maps are cached to disk after download (survives restarts)
- The manifest tracks version numbers for staleness checks
- `MapDownloader.remove_map(name)` clears a specific map
- `MapDownloader.clear_cache()` removes all cached maps
### Configuration
The `MapDownloader` autoload can be configured in-game:
```gdscript
# Set the registry server URL (override default http://127.0.0.1:8090)
MapDownloader.registry_url = "http://maps.myserver.com:8090"
```
The URL can also be set via the `MAP_REGISTRY_URL` environment variable,
which is read in `_ready()`.
### Signals
```gdscript
# Register for map lifecycle events
MapDownloader.map_list_loaded.connect(_on_map_list)
MapDownloader.map_download_progress.connect(_on_download_progress)
MapDownloader.map_download_complete.connect(_on_download_done)
MapDownloader.map_loaded.connect(_on_map_loaded)
```
## Full Pipeline Example
```bash
# 1. Package from the template project
cd maps/my_first_map
godot --headless --script ../../scripts/map_packaging/pack_map.gd \
--map=res://de_my_map.tscn
# 2. Start registry server
cd ../..
python3 scripts/map_packaging/map_registry_server.py --port 8090 &
# 3. Deploy map
cp ~/.local/share/godot/app_userdata/MapTemplate/packed_maps/de_my_map.pck \
scripts/map_packaging/packed_maps/
cp ~/.local/share/godot/app_userdata/MapTemplate/packed_maps/de_my_map.json \
scripts/map_packaging/packed_maps/
# 4. Verify
curl -s http://localhost:8090/maps | python3 -m json.tool
# 5. In-game: connect to server and play the map
```
## Next Steps
- [FAQ & Troubleshooting](06-faq-and-troubleshooting.md) — common issues
@@ -1,238 +0,0 @@
# FAQ & Troubleshooting
Common questions, issues, and solutions encountered while building maps.
## Template Project
### Q: Can I use the template project without the main game repo?
Yes. The template project at `client/map_template/` is a **standalone Godot
project**. Copy it anywhere, open it in Godot, and start building. You only
need the main repo for packaging (`pack_map.gd`) and the registry server
(`map_registry_server.py`).
### Q: Why does Godot say "Import" instead of "Open"?
The template has its own `project.godot`, making it a separate project. Use
**Import** to add it to your Godot project list. You're not opening a scene
within an existing project.
### Q: How do I add the main game's assets to my map?
The template uses placeholder materials. For final maps:
1. Copy your map's `.tscn` into the main project under `client/maps/<name>/`
2. Open it from the main project's scenes tab
3. The main game's modular kit and PBR materials will be available
4. Assign materials to your CSG/MeshInstance3D nodes from the main project's
asset library
### Q: Can I use external models from Blender/Maya?
Yes. Export as `.glb` or `.obj` and import into Godot. Keep triangle counts
within budget (≤ 50K total, ≤ 5K per mesh recommended). Place imported meshes
as children of the map scene root.
## Geometry & CSG
### Q: My map has light leaks at the corners
Thin gaps between CSG blocks let light bleed through. Fix by overlapping CSG
edges by **0.1 units** instead of butting them flush.
```
Bad: Wall ends at x=10.0, floor ends at x=10.0 → gap
Good: Wall extends to x=10.1, floor ends at x=10.0 → overlap
```
### Q: Players can walk through walls
Make sure `use_collision = true` is set on all CSG geometry nodes. CSG nodes
don't have collision by default — you must enable it.
### Q: My map renders as a grey box / no materials
Placeholder materials are normal during construction. For the final look, you
need to:
1. Copy the map to the main project (see above)
2. Assign materials from the game's asset library
3. Re-bake LightmapGI after materials are assigned
### Q: CSG performance is bad in the editor
This is normal. CSG is re-calculated every time you move a node. For testing,
convert stable geometry to MeshInstance3D:
- Right-click CSG parent → **Convert CSG to Mesh**
- Keep a backup copy of the CSG hierarchy in another scene
### Q: How do I make a skybox?
Add a `WorldEnvironment` node and assign an Environment resource:
1. Set **Background** mode to **Sky**
2. Create a new **Sky** resource
3. Assign a **PanoramaSky** or **ProceduralSky** material
4. The sky appears behind your geometry
### Q: My map has no collision after CSG → Mesh conversion
When you convert CSG to MeshInstance3D, collision is not preserved. Add a
`CollisionShape3D` as a child of each MeshInstance3D, or use Godot's
**Mesh → Create Trimesh Collision** (right-click the MeshInstance3D).
## Lighting
### Q: My lightmap bake takes forever
Baking time depends on:
- **texel_scale** — lower = higher resolution = longer bake (try 2.0 for testing)
- **bounces** — fewer bounces = faster bake (use 1 for quick tests, 3 for final)
- **Map size** — larger maps bake longer
- **Light count** — more lights = more calculations
For quick iteration, use Low quality (0) during development and High quality (2)
for the final bake.
### Q: Baked lighting is too dark
- Increase `bounces` to 3 (more light bounce = brighter interiors)
- Add a low-intensity ambient fill (OmniLight3D with Static bake mode)
- Lower the `texel_scale` for more lightmap resolution
- Check that the DirectionalLight3D intensity is sufficient
### Q: Lightmap has visible seams
Seams appear where lightmap texels don't align. Fixes:
- Lower `texel_scale` to 1.0 (higher resolution lightmaps)
- Ensure CSG edges overlap (no gaps)
- Convert CSG to MeshInstance3D before final bake
### Q: Shadows look blocky or jagged
This is Godot's shadow mapping at default resolution. For competitive clarity:
- Keep `shadow_bias` at 0.1 (higher = softer shadows, fewer artefacts)
- Directional shadow `max_distance` at 2030 (shadows beyond this use lower
resolution)
### Q: I see a sphere artifact on my ReflectionProbe
The ReflectionProbe has a `probe_mode` property — if set to **Reflection Probe**
(sphere mode), it renders as a visible sphere in reflections. Change to **Box
Probe** for interior spaces, or disable reflection visibility via the `visible`
property on the probe's MeshInstance3D child (Godot 4.x).
### Q: Can I have a day/night cycle?
No. LightmapGI bakes lighting statically. Dynamic lighting is limited to
≤ 4 real-time lights. Day/night cycles are outside the current scope.
## Validation
### Q: The validator says "No MeshInstance3D nodes found"
Your map is using CSG nodes that haven't been converted to meshes. The
validator counts mesh triangles — CSG nodes are scanned as MeshInstance3D
after conversion. Either:
1. Convert CSG → Mesh for the area you want validated (right-click → Convert CSG to Mesh)
2. This warning is expected during early construction stages
### Q: The validator fails with "Scene cannot be instantiated"
The scene depends on resources that don't exist in the template project. This
usually happens when the map references game assets from the main project.
Either:
1. Run the validator from the **main game project** (`--path client` with the
main `client/project.godot`), or
2. Strip external dependencies — the map should be self-contained
### Q: The validator reports texture errors but my textures are 1024×1024
Check for textures with different aspect ratios. A 2048×512 texture fails
because the larger dimension (2048) exceeds 1024. Resize or tile.
### Q: Can I skip certain validator checks?
Not directly. Run the validator, fix errors, then fix warnings. If a check
produces a false positive, the `@tool` scripts are in
`client/tools/validate_map/` — you can edit the constants (e.g.
`MAX_TOTAL_TRIANGLES`) for custom pipelines.
## Packaging
### Q: The .pck file is very large
The `.pck` includes all dependencies of the scene. Large files mean:
- High-resolution textures (use ≤ 1024×1024 per the budget)
- High-poly meshes (stay under 50K triangles)
- Embedded audio or other large assets
Run the validator first to check budgets.
### Q: I get "Failed to load pack" in-game
Possible causes:
- `.pck` file is corrupted (check SHA-256 checksum)
- The map scene or a dependency path changed between packaging and loading
- The `.pck` was built with a different Godot version than the game
- The resource path in the `.pck` conflicts with a path in the base game
**Solution:** Re-package the map from the same Godot version the game uses.
Verify with `curl` that the download matches the server's checksum.
### Q: Can I update a map without changing the client version?
Yes. The `.pck` is additive content. Update the `.pck` on the registry server
and increment the `version` field in the `.json` metadata. The client will
re-download when it notices the version mismatch.
### Q: Can I host the registry server on a different port?
Yes. Use `--port <port>` or the `MAP_REGISTRY_PORT` environment variable.
The client's `MapDownloader` has a `registry_url` property you can set.
### Q: Can I load more than one .pck at a time?
Yes. `ProjectSettings.load_resource_pack()` is additive. Multiple `.pck` files
can be loaded simultaneously — each adds its resources to the global namespace.
### Q: The registry server returns 404 for my map
- Check that the `.pck` file is in the maps directory (default `./packed_maps/`)
- Check the file extension is `.pck` (lowercase)
- The server auto-scans every 5 seconds — wait or send SIGHUP to force a scan
- Verify by listing: `curl http://localhost:8090/maps`
## General
### Q: Can I make a hostage rescue map?
The SDK is gameplay-agnostic. The groups `ct_spawn`, `t_spawn`, `bomb_site`,
and `buy_zone` are the default set. You can add your own groups (e.g.,
`hostage`, `hostage_zone`) — the game logic discovers them at runtime.
Check the game's plugin API documentation for custom group support.
### Q: Can I use this SDK for a different game?
The SDK is designed for Tactical Shooter's Godot project structure. The
packaging and validation scripts are reusable for any Godot map-authoring
workflow, but the group conventions and gameplay nodes are game-specific.
### Q: The editor validator doesn't run
The `template_map.gd` script uses `@tool` and checks `Engine.is_editor_hint()`.
Make sure:
1. The script is attached to the root node of the scene
2. You're editing the scene in the Godot editor (not running the game)
3. The Output panel is visible (Window → Output)
### Q: How do I report a bug in the SDK?
Open an issue on the project's Gitea repository. Include:
- SDK version (commit hash)
- Godot version
- Steps to reproduce
- Log output (editor or CLI validator)
### Q: Can I contribute validator modules?
Yes. See [Validation — Adding a Custom Validator Module](04-validation.md#adding-a-custom-validator-module).
Submit a pull request with your module and updated documentation.
-203
View File
@@ -1,203 +0,0 @@
# Phase 7: Stack Upgrade — netfox + godot-jolt Migration Plan
## Overview
Migrate from raw **ENetMultiplayerPeer** to **netfox** (foxssake/netfox, 1020⭐ MIT)
for rollback netcode, client prediction, and lag compensation. Add **godot-jolt**
(godot-jolt/godot-jolt, 2535⭐ MIT) for deterministic 128Hz physics.
**Reference architecture:** [naxIO/netfox-cs-sample](https://github.com/naxIO/netfox-cs-sample)
— CS 1.6 clone using netfox rollback patterns (teams, economy, bomb, 6 weapons).
---
## Current Architecture (Pre-Migration)
```
┌───────────────────────────────────────────────────┐
│ project.godot │
│ Autoloads: NetworkManager, ServerConfig, │
│ RoundManager, PluginManager │
│ Physics: 128Hz (Godot built-in) │
│ Rendering: Vulkan Forward+ │
│ Main Scene: server_main.tscn │
└─────────────────────┬─────────────────────────────┘
┌─────────────┴──────────────┐
▼ ▼
┌─────────────────┐ ┌──────────────────────────┐
│ network_manager │ │ SimulationServer │
│ .gd (ENet) │ │ (GDExtension C++) │
│ │ │ │
│ - ENetMultiplayer│ │ - entity spawning │
│ - 3 channels │ │ - apply_input() │
│ - RPC broadcasts │ │ - fire_weapon() │
│ - signals: │ │ - get_entity() │
│ player_* │ └──────────┬────────────────┘
│ round_* │ │
│ score_* │ ┌──────────▼────────────────┐
└─────────────────┘ │ fps_character_controller │
│ .gd (CharacterBody3D) │
┌─────────────────┐ │ │
│ round_manager │ │ - Input → SimulationServer│
│ .gd (state │ │ - move_local() fallback │
│ machine) │ │ - crouch, sprint, jump │
│ │ └────────────────────────────┘
│ - signals: │
│ round_*,score │ ┌──────────────────────────┐
│ - _process tick │ │ Player (Node3D) │
└─────────────────┘ │ - basic BoxMesh │
│ - movement_speed param │
└──────────────────────────┘
```
**Key issue:** ENet has no rollback, no client prediction, no lag compensation.
Players on slower connections will feel inconsistent hit registration.
---
## Target Architecture (Post-Migration)
```
┌────────────────────────────────────────────────────┐
│ project.godot │
│ Autoloads: (same, NetworkManager rewired) │
│ Physics: Jolt @ 128Hz (godot-jolt GDExtension) │
│ Rendering: Vulkan Forward+ │
│ Addons: netfox, netfox.noray, netfox.extras │
└─────────────────────┬──────────────────────────────┘
┌─────────────┴──────────────┐
▼ ▼
┌──────────────────────┐ ┌──────────────────────────┐
│ netfox core │ │ netfox.noray │
│ (NetworkEvents, │ │ (NAT punchthrough) │
│ RollbackSynch., │ └──────────────────────────┘
│ TickInterpolator) │
└──────────┬───────────┘ ┌──────────────────────────┐
│ │ netfox.extras │
▼ │ (BaseNetInput, │
┌──────────────────────┐ │ RewindableStateMachine)│
│ NetworkManager.gd │ └──────────────────────────┘
│ (wrapper over netfox │
│ NetworkEvents + │ ┌──────────────────────────┐
│ MultiplayerSynch.) │ │ godot-jolt │
└──────────┬───────────┘ │ (deterministic physics) │
│ └──────────────────────────┘
┌────────────────────────────────────────────────────┐
│ RollbackSynchronizer (player state) │
│ - _rollback_tick() → deterministic state sync │
│ - TickInterpolator → smooth visuals │
│ - Client prediction: apply input locally, │
│ reconcile from server snapshot │
└────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────┐
│ RewindableAction (hitscan weapons) │
│ - Server-authoritative hit detection │
│ - Server rewinds physics to match client timing │
│ - Per-limb damage: head/chest/waist/legs │
└────────────────────────────────────────────────────┘
```
---
## Migration Phases (Task Breakdown)
### P7.1 Research — netfox migration plan ✅ [THIS DOCUMENT]
### P7.2 Install — netfox addon
1. Download latest release from GitHub
2. Extract addons/netfox/, addons/netfox.noray/, addons/netfox.extras/, addons/netfox.internals/
3. Enable in Project Settings → Plugins
4. Verify `godot --headless --check-only` compiles
### P7.3 Install — godot-jolt physics
1. Download from releases or Asset Library
2. Copy addons/godot-jolt/ into project
3. Enable plugin, configure physics → 128Hz
4. Verify headless compilation
### P7.4 Port — NetworkManager.gd
**Source:** `scripts/network/network_manager.gd` (214 lines, ENet wrapper)
**Changes:**
- Replace `ENetMultiplayerPeer` → netfox `NetworkEvents`
- `start_server()` → use netfox's NetworkEvents server flow
- `join_server()` → use netfox's NetworkEvents client flow
- Player replication signals → netfox `MultiplayerSynchronizer` or `RollbackSynchronizer`
- Round state RPCs (`broadcast_round_*`) → netfox events
- Remove channel system (netfox handles prioritisation internally)
**Equivalent vs New:**
| Current ENet | netfox Equivalent |
|---|---|
| ENetMultiplayerPeer | NetworkEvents (connect/disconnect) |
| @rpc("authority", "call_local", "reliable") | MultiplayerSynchronizer / RollbackSynchronizer |
| broadcast_spawn_player RPC | NetworkEvents + spawn hook |
| broadcast_round_start RPC | NetworkEvents event |
| 3 channels (input/events/telemetry) | Automatic (netfox manages ordering) |
| _process polling | TickInterpolator / _rollback_tick() |
### P7.5 Build — Client Prediction + Rollback
**Key netfox patterns:**
- `RollbackSynchronizer` with `_rollback_tick()` for deterministic state sync
- `BaseNetInput` with `_gather()` for input collection
- `TickInterpolator` for smooth visual interpolation between ticks
- Client-side prediction: apply input immediately, reconcile when server snapshot arrives
### P7.6 Build — Rollback Hitscan + Lag Compensation
**Key netfox patterns:**
- `RewindableAction` for rollback-safe weapon firing
- Server rewinds entity positions to match client's input timestamp
- Hit detection inside rollback loop
- Per-limb damage (head 2x, chest 1.0x, waist 0.75x, legs 0.5x)
### P7.7 Port — RoundManager to netfox
**Source:** `server/scripts/round_manager.gd` (647 lines)
**Changes:**
- Round state signals (`round_started`, `round_ended`, etc.) → netfox events
- `player_died` etc. → netfox replicated state or RPCs
- `get_round_state_snapshot()` → netfox synchronized property
### P7.8 Port — FPS Controller to BaseNetInput
**Source:** `client/characters/character/fps_character_controller.gd` (401 lines)
**Changes:**
- Replace manual `_input_dict``BaseNetInput` subclass
- Use `_gather()` to collect input per tick
- Movement logic becomes server-authoritative (no more `move_local` fallback except singleplayer)
- Keep mouse look, crouch transition, sprint toggle — adapt for netfox tick
### P7.9 Test — Headless Export + Deployment
- `godot --headless --export-debug "Linux/X11" build/server/tactical-shooter.x86_64`
- Server starts clean, clients connect, rollback works across LAN
- Push to Gitea, update systemd service, verify on server
---
## Reference Files
| File | Lines | Key Role |
|---|---|---|
| `scripts/network/network_manager.gd` | 214 | ENet transport wrapper, RPC broadcasts |
| `scripts/network/player.gd` | ~60 | Basic player node |
| `scripts/network/server_main.gd` | ~150 | Server entry point |
| `scripts/network/client_main.gd` | ~120 | Client entry point |
| `client/characters/character/fps_character_controller.gd` | 401 | FPS movement + input to SimulationServer |
| `client/characters/character/fps_camera.gd` | ~80 | Camera look |
| `server/scripts/round_manager.gd` | 647 | Round state machine |
| `server/scripts/plugin_api/plugin_manager.gd` | 573 | Plugin system |
| `scenes/server/server_main.tscn` | 14 | Server scene |
| `scenes/client/client_main.tscn` | 14 | Client scene |
| `scenes/player.tscn` | 15 | Player prefab |
| `project.godot` | 33 | Autoloads, physics settings |
## Resources
- [netfox docs](https://foxssake.github.io/netfox/)
- [netfox GitHub](https://github.com/foxssake/netfox)
- [naxIO/netfox-cs-sample — CS 1.6 reference](https://github.com/naxIO/netfox-cs-sample)
- [godot-jolt GitHub](https://github.com/godot-jolt/godot-jolt)
- [VANTIX tactical shooter reference (C#, architecture only)](https://github.com/justin-bobr/vantix)
-248
View File
@@ -1,248 +0,0 @@
# Netfox Migration Checklist — File-by-File Audit
Generated: 2026-07-02
Task: P7.1 (research — netfox migration plan)
---
## Architecture Overview
```
┌──────────────────────────────────┐
│ network_manager.gd │
│ (ENetMultiplayerPeer transport) │
│ + optional NetworkEvents overlay│
└──────┬───────────────┬───────────┘
│ │
┌────────────┘ └────────────┐
v v
┌─────────────────────┐ ┌─────────────────────┐
│ server_main.gd │ │ client_main.gd │
│ (dedicated server)│ │ (client entry) │
└─────────┬───────────┘ └──────────┬──────────┘
│ │
┌─────────┴───────────┐ ┌──────────┴──────────┐
│ round_manager.gd │ │ round_replicator.gd │
│ (state machine) │ │ (client mirror) │
└─────────────────────┘ └─────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ player.gd │
│ RollbackSynchronizer (dynamic child) │
│ ├── state_properties: pos, is_alive, team_id, spec_tgt │
│ └── input_properties: → PlayerNetInput/* │
│ │
│ PlayerNetInput (child Node) │
│ ├── move_direction, look_yaw, look_pitch │
│ ├── jump, sprint, crouch, shoot, aim │
│ └── connects to NetworkTime.before_tick_loop │
└──────────────────────────────────────────────────────────┘
```
### Headless Compilation
**Passes**`godot --headless --check-only` reports 0 parser errors.
- Runtime warnings (ENet host, netfox unavailable, empty map rotation) are all expected in CI environments.
- All netfox-dependent code paths guarded by `Engine.has_singleton()` + duck-typing.
---
## File-by-File Audit
### 1. network_manager.gd (245 lines) — TRANSPORT LAYER
**Current role:** ENetMultiplayerPeer management + signal relay hub.
**DONE:**
- ✅ Optional NetworkEvents overlay for lifecycle signals (`_try_connect_netfox()`)
- ✅ All netfox refs use `Engine.has_singleton("NetworkEvents")` — no `class_name` refs
**REMAINING (6 legacy RPC broadcasting functions):**
| Line | RPC | netfox Replacement | Status |
|------|-----|--------------------|--------|
| 193 | `broadcast_spawn_player` | Replaced by RollbackSynchronizer state sync. Keep until client discoverse players from state, not RPC. | 🔶 keep temporarily |
| 199 | `broadcast_despawn_player` | Same as above | 🔶 keep temporarily |
| 209 | `broadcast_round_start` | Move to StateSynchronizer on a round-state autoload (P7.7) | ❌ |
| 214 | `broadcast_round_end` | Same as above | ❌ |
| 219 | `broadcast_match_end` | Same as above | ❌ |
| 224 | `broadcast_score_change` | Same as above | ❌ |
**Networking signals (9 total)** — all stay as-is. They are event hooks for consumers (client_main, round_replicator), not data sync.
---
### 2. player.gd (251 lines) — PLAYER ENTITY
**Current role:** Player character with Team/Round integration. Uses RollbackSynchronizer.
**DONE:**
- ✅ RollbackSynchronizer dynamically created: `state_properties=[position, is_alive, team_id, spectate_target_id]`
-`input_properties=[PlayerNetInput/move_direction, look_yaw, look_pitch, jump, sprint, crouch, shoot, aim]`
-`_rollback_tick(delta, tick, is_input)` with deterministic movement
-`_create_rollback_sync_node()` uses `load()` + `new()` from path — no class_name refs
- ✅ Duck-typed netfox checks via `has_method()`, `Engine.has_singleton()`
**REMAINING:**
-`TickInterpolator` child for smooth interpolation (dynamically created in `_setup_tick_interpolator()`)
-`TickInterpolator` on remote player instances for visual rollback
---
### 3. player_net_input.gd (91 lines) — INPUT GATHERING
**Current role:** Tick-synchronized input collector, child of Player.
**DONE:**
- ✅ 8 exported input properties matching RollbackSynchronizer's `input_properties`
- ✅ Connects to `NetworkTime.before_tick_loop`
- ✅ Reads yaw/pitch from parent FPSCharacterController via duck-typing
- ✅ Extends Node directly (avoids `BaseNetInput` headless class_name issues)
**REMAINING:**
- FPSCharacterController duplicates input gathering in `_physics_process` — should read from PlayerNetInput instead → P7.8
---
### 4. server_main.gd (342 lines) — SERVER ENTRY POINT
**DONE:**
- ✅ Uses `NetworkManager.start_server()` for ENet bootstrap
- ✅ Connects to `NetworkManager.player_connected/disconnected`
**REMAINING — 7 RPC call sites:**
| Line | Call | netfox Plan |
|------|------|-------------|
| 120 | `broadcast_round_start.rpc(...)` | StateSynchronizer in P7.7 |
| 142 | `broadcast_round_end.rpc(...)` | StateSynchronizer in P7.7 |
| 178 | `broadcast_match_end.rpc(...)` | StateSynchronizer in P7.7 |
| 184 | `broadcast_score_change.rpc(...)` | StateSynchronizer in P7.7 |
| 288 | `broadcast_spawn_player.rpc(...)` | RollbackSynchronizer state (keep for now) |
| 327 | `broadcast_despawn_player.rpc(...)` | RollbackSynchronizer state (keep for now) |
| 332 | `broadcast_despawn_player.rpc(...)` | RollbackSynchronizer state (keep for now) |
---
### 5. client_main.gd (124 lines) — CLIENT ENTRY POINT
**DONE:**
- ✅ Uses `NetworkManager.join_server()`
- ✅ Connects to `remote_player_spawned/despawned` for visualization
**REMAINING:**
- Creates full Player nodes with RollbackSynchronizer for each remote player (✅ correct pattern)
-`TickInterpolator` on remote player instances (dynamically created in `_setup_tick_interpolator()`)
---
### 6. round_replicator.gd (157 lines) — CLIENT ROUND STATE
**DONE:**
- ✅ Connects to NetworkManager's broadcast signals
- ✅ Maintains round state (round_number, scores, time, freeze)
- ✅ Uses netfox `NetworkTime` singleton for freeze tick tracking (line 84, 123)
**REMAINING:**
- Could be replaced by a `RoundState` autoload with `StateSynchronizer` for rollback-consistent round state (P7.7)
---
### 7. round_manager.gd (647 lines) — SERVER ROUND STATE MACHINE
**DONE:** ✅ Zero networking code. Pure state machine emitting signals. No changes needed.
---
### 8. fps_character_controller.gd (411 lines) — FPS CONTROLS
**DONE:**
-`get_current_yaw()` / `get_current_pitch()` methods for PlayerNetInput
- ✅ Standalone `_move_local()` fallback
- ✅ SimulationServer refs use `Engine.has_singleton()`
**REMAINING (P7.8):**
- `_physics_process` gathers input AND sends to SimulationServer directly — duplicates PlayerNetInput
- Need to read from PlayerNetInput child instead of `_get_move_direction()`
- Clarify: PlayerNetInput → RollbackSynchronizer → server physics → reconcile → FPSCharacterController applies
---
## netfox Pattern Mapping
| Pattern | netfox API | Status |
|---------|-----------|--------|
| Player state sync | RollbackSynchronizer.state_properties | ✅ |
| Input client→server | RollbackSynchronizer.input_properties | ✅ |
| Tick-driven movement | `_rollback_tick(delta, tick, is_input)` | ✅ |
| Client prediction | `enable_prediction = true` | ✅ |
| Smooth interpolation | TickInterpolator | ✅ |
| Round state broadcast | StateSynchronizer | ❌ P7.7 |
| Rollback-safe weapon fire | RewindableAction | ❌ P7.6 |
| Lag comp / hitscan | RewindableAction + mutation | ❌ P7.6 |
| Visual rollback | TickInterpolator + _rollback_tick | ✅ |
| Lifecycle signals | NetworkEvents | ✅ |
---
## Netfox Singleton Availability (Headless)
| Singleton | Editor | Headless | Access Pattern |
|-----------|--------|----------|----------------|
| NetworkEvents | ✅ | ❌ | `Engine.has_singleton("NetworkEvents")` |
| NetworkTime | ✅ | ❌ | `Engine.has_singleton("NetworkTime")` |
| NetworkRollback | ✅ | ❌ | `Engine.has_singleton("NetworkRollback")` |
| NetworkPerformance | ✅ | ❌ | `Engine.has_singleton("NetworkPerformance")` |
All access guarded — no class_name references in our code.
---
## Remaining Work Order (Dependency Graph)
```
P7.6 (RewindableAction) ── needs ── RollbackSynchronizer working (✅ DONE)
P7.5 (TickInterpolator) ── needs ── RollbackSynchronizer working (✅ DONE)── [[DONE]] ✅
P7.7 (Round state netfox) ── needs ── NetworkEvents working (✅ DONE)
P7.8 (FPSController refactor) ── needs ── P7.6 input flow clarified
P7.9 (LAN test + deploy) ── needs ── all of the above
```
**Recommended order:** P7.6 → P7.5 → P7.7 → P7.8 → P7.9
---
## Files NOT Needing Changes
| File | Reason |
|------|--------|
| `server/scripts/round_manager.gd` | Pure state machine, no networking |
| `scripts/config/server_config.gd` | Config loader, no networking |
| `scripts/network/netfox_bootstrap.gd` | No-op placeholder |
| `server/scripts/plugin_api/plugin_manager.gd` | Plugin mgmt, no networking |
| `client/weapons/data/weapon_registry.gd` | Data-only |
---
## Risk Assessment
| Risk | Impact | Likelihood | Mitigation |
|------|--------|------------|------------|
| Headless compilation fails | High | Low | All netfox refs use has_singleton/has_method. Current check passes. |
| RollbackSynchronizer vs PlayerNetInput property mismatch | Medium | Low | `input_properties` paths verified against @export var names — matched. |
| Dual input path (SimulationServer + RollbackSynchronizer) | High | Medium | FPSCharacterController sends to both — needs architectural cleanup (P7.8) |
| netfox API incompatibility with Godot 4.7 | Medium | Low | netfox 1.35.3 targets 4.3+; manual verification done — compilation passes |
---
## Key Files Summary
| File | Lines | Netfox Status | RPCs | Signals | Changes Needed |
|------|-------|---------------|------|---------|----------------|
| network_manager.gd | 245 | ✅ overlay wired | 6 ❌ | 9 ✅ | Remove 6 RPCs post-P7.7 |
| player.gd | 251 | ✅ rollback + tick + interpolator | 0 | 0 | ✅ |
| player_net_input.gd | 91 | ✅ tick gather | 0 | 0 | None atm |
| server_main.gd | 342 | ✅ thin layer | 7 call sites ❌ | 2 | Migrate to StateSynchronizer P7.7 |
| client_main.gd | 124 | ✅ thin layer | 0 | 2 signal connects | None atm |
| round_replicator.gd | 157 | 🔶 signal-driven | 0 | 5 connects + 4 emits | Migrate to StateSynchronizer P7.7 |
| round_manager.gd | 647 | ✅ no changes | 0 | 0 | None |
| fps_character_controller.gd | 411 | 🔶 has getters | 0 | 0 | Input flow refactor P7.8 |
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

@@ -0,0 +1,30 @@
# BaseNetInput
Base class for Input nodes used with rollback.
During rollback, multiple logical ticks are simulated in the span of a single
network tick. Since these are just logical ticks, no actual input arrives during
them from the input devices.
The solution is to gather input before the tick loop, and use that input for
any new ticks simulated during the rollback.
## Gathering input
This class provides a virtual `_gather` method that you can override. Set the
variables configured in [RollbackSynchronizer] in your own implementation:
```gdscript
extends BaseNetInput
var movement: Vector3 = Vector3.ZERO
func _gather():
movement = Vector3(
Input.get_axis("move_west", "move_east"),
0,
Input.get_axis("move_north", "move_south")
)
```
[RollbackSynchronizer]: ../../netfox/nodes/rollback-synchronizer.md
@@ -0,0 +1,80 @@
# NetworkSimulator
During testing, it is crucial to test your game under realistic network
conditions, including latency and potentially packet loss.
Network conditions can be simulated using [clumsy], [netem], or with *netfox*'s
*NetworkSimulator*. It auto-connects instances when launched from the editor,
and simulates various network configurations.
## How to Use
Enable and configure *Autoconnect* in the project settings.
![Autoconnect Project settings](../assets/network-simulator.png)
When your game launches one instance will start an [ENetMultiplayerPeer] server
and the rest will connect to it.
Either a `NetworkSimulator.server_created` or
`NetworkSimulator.client_connected` signal will be fired which you can use to
bootstrap your game code to.
!!!note
*NetworkSimulator* will only work when the game is ran from the editor.
Otherwise it will disable itself, regardless of project settings. This is a
failsafe to avoid affecting production builds of your games.
## Configuration
Hostname
: The hosting address. Usually `127.0.0.1` but can be changed to `*`, if you
want other machines to be able to join.
Server Port
: Which port to listen on. A second server port with latency / loss will open
one number higher if they are set to more than zero.
Use Compression
: Will make use of ENET's range encoder to keep packet sizes down.
Simulated Latency ( ms )
: Traffic delay, in milliseconds.
Simlated Packet Loss Chance
: What percentage of packets will to drop, simulating bad network conditions.
## Running in CI and other environments
In certain cases, you might not need the autoconnect feature to run, even if
it's enabled in the project settings.
One example could be running checks on your project in CI, or wanting to run a
single script.
In these cases, Godot will still identify itself as running in editor, so the
autoconnect feature will start hosting, keeping the process alive. This leads
to timeout errors, or the process running indefinitely.
To avoid such cases, set any of the following environment variables to any
non-empty string:
- `CI`
- `NETFOX_CI`
- `NETFOX_NO_AUTOCONNECT`
If *NetworkSimulator* detects any of the above environment variables, it will
deactivate, regardless of project settings.
!!!tip
Github Actions automatically sets the `CI` environment variable.
*NetworkSimulator* will automatically disable itself when running in a
Github Actions workflow.
See the [Github blog entry] for more details.
[clumsy]: https://jagt.github.io/clumsy/
[netem]: https://man7.org/linux/man-pages/man8/tc-netem.8.html
[ENetMultiplayerPeer]: https://docs.godotengine.org/en/4.1/classes/class_enetmultiplayerpeer.html
[Github blog entry]: https://github.blog/changelog/2020-04-15-github-actions-sets-the-ci-environment-variable-to-true/
+137
View File
@@ -0,0 +1,137 @@
# NetworkWeapon
Class to simplify writing networked weapons.
A weapon, in this context, is anything that can be fired and spawn objects (
projectiles ) upon being fired.
## Responsive projectiles
Upon firing, sending a request to the server and waiting for the response with
the projectile would introduce a delay. Doing a full-on state synchronization
with [MultiplayerSynchronizer] or [RollbackSynchronizer] can be unfeasible with
too many projectiles, and unnecessary, since most of the time, projectiles act
and move the same way regardless of their surroundings.
Instead, upon firing, a projectile is spawned instantly. At the same time, a
request is sent to the server. If the server accepts the projectile, it will
spawn it and broadcasts its starting state. Since the server's state is the
source of truth, the projectile's local state will be updated with the
difference. This is called *reconciliation*.
If the client requests a projectile with an unlikely state, it will be
rejected. This is to avoid players cheating, for example by requesting
projectiles at a more advantageous position than they're at.
If the server is too strict with what difference is considered acceptable and
what not, legitimate players may get cases where they fire a projectile which
disappears after a short time period.
## Implementing a weapon
*NetworkWeapon* provides multiple functions to override. Make sure that all
these methods work the same way on every player's game, otherwise players will
experience glitches.
*_can_fire* returns a bool, indicating whether the weapon can be fired. For
example, this method can return false if the weapon was fired recently and is
still on cooldown. **Do not** update state here. Use *_after_fire* instead.
*_can_peer_use* indicates whether a given peer can fire the weapon. Due to the
way RPCs are set up under the hood, any of the players can try to fire a
weapon. Use this method to check if the player trying to fire has permission,
e.g. a player is not trying to use someone else's weapon.
*_after_fire* is called after the weapon is successfully fired. Can be used to
update state ( e.g. last time the weapon was fired ) and play sound effects.
*_spawn* creates the projectile. Make sure to return the created node.
*_get_data* must return the projectile's starting state in a dictionary. This
can contain any property that is relevant to the projectile and must be
synchronized. For example, *global_transform* is important to ensure that the
projectile starts from the right position. On the other hand, projectile speed
does not need to be captured if it's the same for every projectile.
*_apply_data* must apply the captured properties to a projectile.
*_is_reconcilable* checks if the difference between two projectile states ( as
captured by *_get_data* ) is close enough to be allowed. Can be used to reject
cheating.
*_reconcile* adjusts the projectile based on the difference between the local
and server state.
## Specializations
*NetworkWeapon* extends [Node]. This also means that anything extending
*NetworkWeapon* is also a node, and thus can't have a position for example.
Two specialized classes are provided - *NetworkWeapon3D*, and *NetworkWeapon2D*
- extending Node3D and Node2D respectively.
This way, weapons can have transforms and have a presence in the game world.
They also take care of reconciliation, implementing *_get_data*, *_apply_data*,
*_is_reconcilable*, and *_reconcile*. These can be overridden, but make sure to
to call the base class with *super(...)*.
Reconciliation is based on distance, and can be configured with the
*distance_threshold* property.
Under the hood, these specializations create a special *NetworkWeapon* node,
that proxies all the method calls back to the specialization. This is a
workaround to build multiple inheritance in a single inheritance language.
## Compensating latency
Whenever the weapon is fired, it takes time for that event to arrive at the
host. To combat this, along with the weapon being fired, the firing's tick is
also sent. This way, the host doesn't only know that the weapon was fired, but
it also knows *when*.
To retrieve the exact tick, call *get_fired_tick()*.
This can be used to adjust the created projectile's simulation, e.g. by
simulating it from its spawn to the current tick in `_after_fire()`:
```gdscript
func _after_fire(projectile: Node3D):
last_fire = get_fired_tick()
for t in range(get_fired_tick(), NetworkTime.tick):
if projectile.is_queued_for_deletion():
break
projectile._tick(NetworkTime.ticktime, t)
```
!!!note
To track the tick the weapon was last fired ( e.g. for cooldowns ), make sure
to use `get_fired_tick()`, instead of `NetworkTime.tick`.
## Hitscan weapons
Use *NetworkWeaponHitscan3D* to build networked hitscan weapons. It builds upon
the same principle as *NetworkWeapon*, but modified for hitscan.
Most of the callbacks are the same, with the following differences:
*_spawn* is not used, as there's no projectiles involved.
*_on_fire* is called whenever the weapon is successfully fired. Can be used to
implement effects on firing, such as sound or visual effects.
*_on_hit* is called whenever the weapon hits a target. Depending on
configuration, this may be another player, a different character, or just level
geometry. The raycast result is passed as a parameter to distinguish between
hits.
Reconciliation is handled under the hood - *_get_data*, *_apply_data*,
*_is_reconcilable*, and *_reconcile* do not need to be implemented.
Hitscan weapons don't implement *get_fired_tick()*, as there's no projectile to
simulate.
[MultiplayerSynchronizer]: https://docs.godotengine.org/en/stable/classes/class_multiplayersynchronizer.html
[RollbackSynchronizer]: ../../netfox/nodes/rollback-synchronizer.md
[Node]: https://docs.godotengine.org/en/stable/classes/class_node.html
+86
View File
@@ -0,0 +1,86 @@
# Physics
At the time of writing official Godot releases have no support for manually
stepping physics simulations. As such if you want to use physics nodes with
rollback such as RigidBody you will need to run either a fork that supports
stepping or use an alternate physics addon that exposes stepping.
!!!tip
An example game demonstrating physics and rollback is available: [Godot
Rocket League].
## Known Options
- **Godot with Stepping PR**: Build the Godot editor and relevant export
templates manually with the [physics stepping PR] applied. See the [Godot
documentation] for compilation instructions.
- **Rapier Physics Addon**: A third-party 2D/3D physics engine for Godot with
stepping support. Visit [godot.rapier.rs] for details.
- **Blazium Fork**: A Godot fork with enhanced physics features, including
stepping. Learn more at [blazium.app].
!!!tip
For using Godot with the Stepping Physics PR applied, a [community run
repository] is available with a custom build. Note that this repository and
its builds are provided as-is, and are not associated with Godot nor the PR's
author.
!!!note
The current version of the Rapier drivers were tested against Godot Rapier
version **0.8.26**.
## Enabling Physics Engine Rollback
To enable physics rollback, add the appropriate physics driver node to the root
of your scene tree based on your physics engine. Because stepping methods are
unavailable in standard Godot the classes are hidden to avoid compile errors.
You will need to enable them in the editor by going to *Project -> Tools ->
Enable physics driver*
- PhysicsDriver2D or PhysicsDriver3D for Godots default physics or Blazium.
- RapierPhysicsDriver2D or RapierPhysicsDriver3D for the Rapier Physics Addon.
These nodes disable Godots default physics processing and step the physics
simulation at **netfox**s network tick rate.
![Enable physics driver menu](../assets/physics-enable.png)
## Physics Driver Configuration
**Physics Factor** - Controls the number of physics steps per network tick.
For example, if your network tick rate is 30 Hz (one tick every ~33ms) but you
need a 60 Hz physics simulation for smoother collisions, set this to 2 to run
two physics steps per tick.
**Rollback Physics Space** - When enabled, rolls back all physics objects in
the scene tree. Depending on how complex your scene tree is you may wish to
only rollback specific nodes for performance rather than the entire simulation
space.
## NetworkRigidBody
NetworkRigidBody2D and NetworkRigidBody3D nodes enable RigidBody
synchronization with RollbackSynchronizer, keeping clients in sync with the
servers physics simulation. These nodes can replace standard RigidBody nodes
with some minor setup.
!!!note
Avoid using StateSyncronizer with NetworkRigidBody, as it doesn't participate
in rollback it will end up stepping faster than other nodes.
To make use of NetworkRigidBody you need to:
1. Configure your RollbackSynchronizer to include the NetworkRigidBody's
`physics_state` as a state property.
2. Move physics-related logic from `_physics_process` to
`_physics_rollback_tick`.
![State configuration for NetworkRigidBody](../assets/network-rigid-body.png)
[Godot Rocket League]: https://github.com/albertok/godot-rocket-league
[community run repository]: https://github.com/albertok/godot/releases
[physics stepping PR]: https://github.com/godotengine/godot/pull/76462
[Godot documentation]: https://docs.godotengine.org/en/stable/contributing/development/compiling/index.html
[godot.rapier.rs]: https://godot.rapier.rs/
[blazium.app]: https://blazium.app/
@@ -0,0 +1,66 @@
# RewindableRandomNumberGenerator
A random number generator that can be used inside the [rollback tick loop].
An important point for writing code that works well in rollback is that it
behaves the same given the same circumstances, no matter how many times it's
run. It also must behave the same no matter which peer is running it.
Godot's built-in [RandomNumberGenerator] is not aware of rollback, so it will
consistently return different numbers for each tick, and potentially on each
peer.
To fix that, the *RewindableRandomNumberGenerator* generates the same random
sequences on each peer, and for each tick.
It implements most of the same methods, so it's close to a drop-in replacement.
## Creating the generator
The *RewindableRandomNumberGenerator* requires a seed upon initialization. This
seed must be the same on all peers.
!!!warning
If two *different* RNGs use the *same* seed, they will also generate the
same random numbers. Make sure that you use different seeds for different
objects.
For RNG's used by singletons, hard-coding different seed values is a very
simple approach:
```gd
var rng := RewindableRandomNumberGenerator.new(15)
```
If these objects also have names or similar, consistent identifiers, a simple
hash works well too:
```gd
var rng := RewindableRandomNumberGenerator.new(hash("Exit beacon"))
```
For dynamically spawned objects, or just to avoid the possibility of a human
error, the node path can be hashed:
```gd
var rng := RewindableRandomNumberGenerator.new(hash(get_path()))
```
This assumes that the same node will be spawned under the same path on all
peers, which is also a requirement for RPCs to work.
## Generating random numbers
The *RewindableRandomNumberGenerator* can be used in the same way as Godot's
built-in [RandomNumberGenerator]. All the per-peer and per-tick consistency is
ensured under the hood:
```gd
var rng := RewindableRandomNumberGenerator.new(0)
var dice_roll := rng.randi_range(1, 6)
```
[rollback tick loop]: ../../netfox/guides/network-rollback.md
[RandomNumberGenerator]: https://docs.godotengine.org/en/stable/classes/class_randomnumbergenerator.html
@@ -0,0 +1,214 @@
# RewindableStateMachine
Rollback-aware state machine implementation.
State machines are often used in games to implement different behaviors.
However, most implementations are not prepared for rollbacks. This class
provides an extensible implementation that can be used alongside a
[RollbackSynchronizer].
For a full example, see [multiplayer-state-machine].
## Creating a state machine
The first step is to add the RewindableStateMachine to your scene. It also
requires a RollbackSynchronizer that manages its `state` property. Unless these
conditions are satisfied, an editor warning will be displayed.
!!!note
Editor warnings are only updated when the node tree changes. Configuration
changes don't trigger an update. You may need to reload the scene after
fixing a warning, or make a tree change, like deleting and re-adding a node
by cutting and pasting.
![RewindableStateMachine with
RollbackSynchronizer](../assets/rewindable-state-machine-rollback.png)
Notice the RollbackSynchronizer added as a sibling to the
RewindableStateMachine, and having its `state` property configured.
## Implementing states
States are where the custom gameplay logic can be implemented. Each state must
be an extension of the RewindableState class, and added as a child to the
RewindableStateMachine.
States react to the game world using the following callbacks:
`tick(delta, tick, is_fresh)`
: Called for every rollback tick the state is active.
`enter(previous_state, tick)`
: Called when entering the state.
`exit(next_state, tick)`
: Called when exiting the state.
`can_enter(previous_state)`
: Called before entering the state. The state is only entered if this method
returns true.
`display_enter(previous_state, tick)`
: Called before displaying the state.
`display_exit(next_state, tick)`
: Called before displaying a different state.
You can override any of these callbacks to implement your custom behaviors.
For example, the snippet below implements an idle state, that transitions to
other states based on movement inputs:
```gdscript
extends RewindableState
@export var input: PlayerInputStateMachine
func tick(delta, tick, is_fresh):
if input.movement != Vector3.ZERO:
state_machine.transition(&"Move")
elif input.jump:
state_machine.transition(&"Jump")
```
Transitions are based on *node names*, i.e. calling `transition(&"Move")` will
transition to a state node called *Move*.
![RewindableStates under a state
machine](../assets/rewindable-state-children.png)
States must be added as children under a RewindableStateMachine to work.
## Using signals instead of classes
*RewindableState* nodes also emit signals during their lifetime. This enables
an alternate style of implementing states, by connecting handlers to different
signals. This can be useful if you want to keep all your logic in a single
script, among others.
Each of these signals correspond to a callback explained above:
* `on_enter()``enter()`
* `on_tick()``tick()`
* `on_exit()``exit()`
* `on_display_enter()``display_enter()`
* `on_display_exit()``display_exit()`
## Adding states
Once implemented, add the state nodes as children of the
*RewindableStateMachine* in the Scene Tree. When doing this programmatically,
make sure to set the state's `owner` to the target *RewindableStateMachine*.
Without the owner set, the *RewindableStateMachine* won't recognize the state.
## Display State vs State
There's two sets of callbacks for state transition - `enter()`/`exit()` and
`display_enter()`/`display_exit()`.
The `enter()`/`exit()` callbacks are intended for implementing game logic. The
`display_enter()`/`display_exit()` are intended for implementing presentation
logic - visuals, animations, sound effects, etc.
The same applies to `on_state_changed` vs. `on_display_state_changed`.
Let's take an example. The game is currently on tick @8. It needs to re-run
ticks @0 to @8 during rollback. In these ticks, the player moves a bit,
performs a jump, and then stops after moving a bit more:
```puml
@startuml
concise "Player" as P
@0
P is Idle
@1
P is Moving
@3
P is Jumping
@5
P is Moving
@8
P is Idle
@enduml
```
This will trigger the following state changes:
* Tick@1: Idle → Moving
* Tick@3: Moving → Jumping
* Tick@5: Jumping → Moving
* Tick@8: Moving → Idle
For each of the above, the `on_state_changed` signal will be emitted, and the
`enter()`/`exit()` callbacks will be triggered.
This makes the above callbacks ideal for game logic, e.g. adding an upward
velocity to the player when they enter the `Jumping` state.
Note that the *displayed* state does not change. Before the rollback loop, the
player's state was `Idle`. After the rollback loop, the player's state is also
`Idle`. Even though the player has ran and performed a jump, it wouldn't make
sense to change their animation or play any sound effect.
Let's take a different rollback example:
```puml
@startuml
concise "Player" as P
@5
P is Moving
@8
P is Idle
@9
P is Jumping
@enduml
```
In this case, the display state *did* change. Before the rollback loop, the
player's state was `Moving`. After the rollback loop, the player's state is
`Jumping`. It would make sense to change the player's animation and play a
jumping sound effect.
This can be done by using the display state callbacks - the
`on_display_state_changed` signal, and the `display_enter()`/`display_exit()`
methods.
## Caveats
RewindableStateMachine runs in the [rollback tick loop], which means that all
the [Rollback Caveats] apply.
In addition, rollback ticks are only ran for nodes that have known inputs for
the given tick, and *need* to be simulated - either on the server to determine
the new state, or on the client to predict. In practice, ticks are usually only
ran on the host owning state and the client owning inputs. The rest of the
peers use the state broadcast by the host.
**This means that transition callbacks are not always ran.** This is by design
and expected ( see [#327] ).
As a best practice, in the `enter()`, `exit()` callbacks and the
`on_state_changed` signal, only change game state - i.e. properties that are
configured as state in [RollbackSynchronizer].
To update visuals - e.g. change animation, spawn effects, etc. -, use either
the `on_display_state_changed` signal, or the `display_enter()` and
`display_exit()` callbacks to react to state transitions.
[multiplayer-state-machine]: https://github.com/foxssake/netfox/tree/main/examples/multiplayer-state-machine
[RollbackSynchronizer]: ../../netfox/nodes/rollback-synchronizer.md
[rollback tick loop]: ../../netfox/guides/network-rollback.md#network-rollback-loop
[Rollback Caveats]: ../../netfox/tutorials/rollback-caveats.md
[#327]: https://github.com/foxssake/netfox/issues/327#issuecomment-2491251374
+33
View File
@@ -0,0 +1,33 @@
# WindowTiler
A developer convenience feature that automatically tiles the launched windows
when working from the editor.
![Window Tiler](../assets/window-tiler.gif)
## Limitations
### Borderless mode on Linux
Setting window position and size works inconsistently under Linux at the time
of writing. Your mileage may vary based on your desktop environment and
distribution.
In case the windows don't tile properly with *Borderless* enabled, disabling it
is a fallback.
### Window decorations
At the time of writing, there is no known and consistent way to compensate for
window decoration size and offset. In practice, this means that windows may
slightly overlap.
## Configuration
*Auto Tile Windows* Enables auto tiling from editor launches.
*Screen* Which screen number to move and tile the windows to.
*Borderless* Enable borderless mode to make the most out of the screen real
estate.
+181
View File
@@ -0,0 +1,181 @@
# Noray
Singleton providing [noray] integration.
*noray* is a backend application that orchestrates connection between players.
To do this, players send a connection request to *noray*, and in turn *noray*
sends the players' external addresses to eachother. It is then up to the
players to conduct a handshake process.
If the handshake fails, players can request a *relay* from *noray*. In these
cases, *noray* will receive data from one player and forward it to the other,
acting as a middle man.
## Identifiers
*noray* identifies players with two different IDs: OpenID and PrivateID.
*OpenID* is public, and can be shared with other players. This ID is used to
identify hosts when connecting to games.
*PrivateID* is only sent to the player it identifies and should **never** be
shared. Acts similar to a password, and is used to authorize commands.
## Relays and NAT Punchthrough
*noray* provides two methods of connecting players.
*NAT Punchthrough* relies on the NAT table. Players must continuously send data
to eachother until either two-way communication is established, or a timeout is
reached. For certain router setups, NAT punchthrough does not work.
See: [NAT Punch-through for Multiplayer Games]
For *relays*, *noray* allocates a specific port to a given player. When *noray*
receives data on this port, it will forward it as-is to the player. As long as
*noray* is accessible over the internet, relays should work reliably no matter
the router setup.
## Registering with noray
To start using *noray*, connect to a *noray* server, request IDs by
registering, and then register the remote address:
```gdscript
var host = "some.noray.host"
var port = 8890
var err = OK
# Connect to noray
err = await Noray.connect_to_host(host, port)
if err != OK:
return err # Failed to connect
# Register host
Noray.register_host()
await Noray.on_pid
# Register remote address
# This is where noray will direct traffic
err = await Noray.register_remote()
if err != OK:
return err # Failed to register
```
By calling `Noray.register_host()`, a request is sent to *noray*. Once a
response is received, both the `on_pid` and `on_oid` signals are fired, for
receiving the PrivateID and OpenID respectively.
The remote address must be registered so that *noray* knows where to direct
other players wanting to connect. This process also sets `Noray.local_port`,
which is where traffic can be received through *noray*.
## Starting a host
To host a game, start listening on *noray*'s local port:
```gdscript
var peer = ENetMultiplayerPeer.new()
var err = peer.create_server(Noray.local_port)
if err != OK:
return false # Failed to listen on port
```
The rest is handled by *noray*.
## Starting a client
To connect to a game, send a request to *noray* with the host's OpenID.
```gdscript
var oid = "abcd1234"
# Connect using NAT punchthrough
Noray.connect_nat(oid)
# Or connect using relay
Noray.connect_relay(oid)
```
Once the request is sent, *noray* will send a message to both the client and
the host players to connect to each other. The actual connection is done by
handling signals.
!!!note
*noray* provides no functionality to share OpenIDs. For development, you
can display the OpenID in a textbox, letting players copy it and share over
their preferred messaging app.
## Handling signals
When a connect message is received, the appropriate signal is fired.
*on_connect_nat* is fired to connect with NAT punchthrough.
*on_connect_relay* is fired to connect to a relay.
In both cases, a public address is passed to the signal handler, in the form of
an address string and a port. Handlers must conduct a handshake ( e.g. with
[PacketHandshake] ) and connect if successful.
Client example:
```gdscript
func _ready():
Noray.on_connect_nat.connect(_handle_connect)
Noray.on_connect_relay.connect(_handle_connect)
func _handle_connect(address: String, port: int) -> Error:
# Do a handshake
var udp = PacketPeerUDP.new()
udp.bind(Noray.local_port)
udp.set_dest_address(address, port)
var err = await PacketHandshake.over_packet_peer(udp)
udp.close()
if err != OK:
return err
# Connect to host
var peer = ENetMultiplayerPeer.new()
err = peer.create_client(address, port, 0, 0, 0, Noray.local_port)
if err != OK:
return err
return OK
```
!!!note
Make sure to **always** specifiy the local port for the client - this is
the only port noray recognizes, and failing to specify it will result in
broken connectivity.
Host example:
```gdscript
func _ready():
Noray.on_connect_nat.connect(_handle_connect)
Noray.on_connect_relay.connect(_handle_connect)
func _handle_connect(address: String, port: int) -> Error:
var peer = get_tree().get_multiplayer().multiplayer_peer as ENetMultiplayerPeer
var err = await PacketHandshake.over_enet(peer.host, address, port)
if err != OK:
return err
return OK
```
!!!note
The host handshake is a bit different, as it can't receive manual packets,
only send them. So it assumes that the target is always responsive, and
just blasts them with a bunch of packets. If the target is indeed
responsive, it can connect. If not, nothing happens, as expected.
[noray]: https://github.com/foxssake/noray
[NAT Punch-through for Multiplayer Games]: https://keithjohnston.wordpress.com/2014/02/17/nat-punch-through-for-multiplayer-games/
[PacketHandshake]: ./packet-handshake.md
@@ -0,0 +1,116 @@
# PacketHandshake
Singleton implementing handshake over UDP.
The point of the handshake itself is to confirm two-way connection between
two parties - i.e. both parties can receive message from the other and
receive acknowledgement from the other that their messages have arrived.
This is an important step before establishing connection for actual game
play, as this lets both the client's and server's routers ( if any ) know
that traffic is expected and should be let through.
## NAT punchthrough
Most players are behind a router. Routers are directly connected to the
internet, and protect machines behind them from unwanted traffic.
When routers receive packets from an unknown source, those packets are rejected
and don't reach the player's device. When data was sent to that address first,
routers see traffic as a reply and allow incoming data.
To take an example, if a random PC starts sending traffic your way, the router
will reject it. If you send data to the host behind godotengine.org, your
router will allow incoming traffic from it. Otherwise, you wouldn't be able to
open the website in your browser, as the incoming HTTP response would be
rejected.
This can be used to our advantage. If both players start sending traffic
towards eachother, eventually the routers will assume it's a response to some
request and allow the traffic.
> This is a very simplified description of how routers work. NAT punchthrough
> does not always work. For further reading, see [Network address translation].
## Handshake process
To confirm two-way connectivity, a string is sent back and forth, encoding the
player's knowledge about the connection:
The *Read flag* is set once we have received data from the other player.
The *Write flag* is set once we send data to the other player. Since data is
always sent, this flag is always set.
The *Duplex flag* is set when we have received data from the other player
knowing that they have also received data from us. This means that data flows
both ways.
The handshake process is successful when both players have the *Duplex flag*
set *and* both players know that the other player has the *Duplex flag* set.
Each flag is encoded as its specific character or a hyphen. The encoded string
is prepended with a dollar sign. For example:
* *$rw-* means that we have sent and received data from the other player
* *$rwx* means that the *read*, *write*, and *duplex* flags are all set
Here's the handshake process illustrated:
```puml
@startuml
actor "Player A" as A
entity "Router A" as RA
boundary Internet as Net
entity "Router B" as RB
actor "Player B" as B
A ->x RB : $-w-
note over RB: Packet denied
B ->x RA : $-w-
note over RA: Packet denied
note over RA, RB: NAT table updated on both routers
A -> B: $-w-
note over RB: Packed allowed
B -> A: $-w-
note over RB: Packed allowed
A -> B: $rwx
B -> A: $rwx
note over Net #lightgreen: Connection established
@enduml
```
## Handshake over PacketPeer
To run the handshake over raw UDP, call `PacketHandshake.over_packet_peer()`. The
specified PacketPeer will be used to send data until two-way connectivity is
confirmed or the timeout is reached. Between every packet sent, it takes a
short pause.
!!!note
The PacketPeer must already be configured with a target address.
## Handshake over ENet
If the game is already running, the handshake must be done over the already
active connection. For this case, use `PacketHandshake.over_enet_peer()`. If
the [ENetMultiplayerPeer] is not accessible from where you want to do the
handshake, use `PacketHandshake.over_enet()`.
This connection can't be used to receive custom packets, only to send them. So
the target address will be spammed with traffic confirming two-way connectivity
until timeout. Handshake will always be considered successful.
If the connectivity exists, players will simply connect. Otherwise,
connectivity will fail as expected, regardless of the handshake results.
[Network address translation]: https://en.wikipedia.org/wiki/Network_address_translation
[ENetMultiplayerPeer]: https://docs.godotengine.org/en/4.1/classes/class_enetmultiplayerpeer.html
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

@@ -0,0 +1,64 @@
# Authoritative servers
The idea behind multiplayer servers is replicating state. As long each player
sees approximately the same things happening on their screen, the illusion of a
shared world works.
## Naive replication
To implement state replication, we could say that each player is responsible
for their own state. Players see the effects of their input instantly, as they
own their state and thus their avatar.
The issue is that clients can't be trusted. Your game client is distributed to
players, who run it in various environments. These environments are out of the
developer's control, and provide an attack surface for cheats.
For example, a modified game client might always report full HP no matter how
many hits the player takes. If each player is responsible for their own state,
the cheating player's full-HP state will be replicated to everyone else.
## Server as the source of truth
What can be controlled is the server, with dedicated hosting. Thus, the server
can be the single source of truth - or in other words, authoritative. Clients
send their inputs to the server, and the server responds with the updated game
state.
This makes cheating difficult, as players have limited influence over the game
world.
Game code can also be simplified - everything that affects the gameplay is run
on the server, while other things such as visual effects are run on the
clients.
The tradeoff is that it takes time for the updated game state to arrive from
the server. This necessitates techniques that mask this delay, such as
[Client-side prediction and Server reconciliation].
## Other approaches
Server-authoritative gameplay with CSP is not a silver bullet unfortunately,
and different games may require different approaches to network state
replication.
One good example is RTS games. These games can have 50+ or even hundreds of
units navigating the map and interacting. Broadcasting all of their state to
all of the players from the server may not always be feasible.
Instead, players broadcast their actions ( inputs ) to each other and update
their game state in lockstep. While this approach can scale up to hundreds of
units, it has other drawbacks. One of these is developing the game in such a
way that the simulation is exactly the same across multiple CPU architectures
down to each bit.
For more on this approach, see: [1500 Archers on a 28.8: Network Programming in
Age of Empires and Beyond]
For more approaches, see: [Networking for Physics Programmers]
[1500 Archers on a 28.8: Network Programming in Age of Empires and Beyond]: https://www.gamedeveloper.com/programming/1500-archers-on-a-28-8-network-programming-in-age-of-empires-and-beyond
[Networking for Physics Programmers]: https://www.gdcvault.com/play/1022195/Physics-for-Game-Programmers-Networking
[Client-side prediction and Server reconciliation]: https://www.gabrielgambetta.com/client-side-prediction-server-reconciliation.html
@@ -0,0 +1,35 @@
# Servers, clients, and ownership
Much of this documentation discusses things in context of servers and clients.
This page is intended to clear up how this translates to Godot's concept of
multiplayer ownership.
## Ownership in Godot
In Godot's multiplayer system, each node belongs to a multiplayer peer, i.e. a
player. This can be set from scripts, and is not replicated. This means that
the logic assigning ownership to nodes must produce the same result on every
machine for things to work consistently.
## Ownership in netfox
To mesh better with Godot's existing conventions, *netfox* doesn't work in
terms of server and client, but uses ownership instead.
Whenever *the server* is mentioned, it refers to a given node's owner.
In practice, this means that nodes representing game state are and should be
owned by the server.
## Limitations
At the time of writing, ownership is hard-coded in some cases. One such case is
*NetworkTime*, which is always owned by the host peer and always takes the host
peer's time as reference.
This means that peer-to-peer games are not officially supported by *netfox*,
but might be able to work with some workarounds. If feasible, you can build
self-hosted games by including *netfox.noray*.
In theory, multiple players can own different parts of the game state, but
*netfox* is not tested for such use cases.
+100
View File
@@ -0,0 +1,100 @@
# Interpolators
Tracks interpolators for various data types. Provided as a static class.
To smooth out motion between network ticks, [TickInterpolator] interpolates
nodes' state properties between the current and the previous tick. The type of
data to be interpolated is not known in advance, and can be any built-in or
even custom type configured by the developer.
*Interpolators* provides methods to register interpolators for any data type,
and even provides some for built-in data types.
## Interpolating values
*Interpolators* can be used to interpolate between any two values, as long as
they hold the same data type:
```gdscript
extends Node3D
@export var target_node
@export var approach_time = 0.5
func _process(delta):
# Approach target node, if it exists
if target_node:
var from_xform = global_transform
var to_xform = target_node.global_transform
var factor = delta / approach_time
global_transform = Interpolators.interpolate(from_xform, to_xform, factor)
```
Note that in this case, *Interpolators* will try to look up the appropriate
interpolator based on the provided values. If no interpolator is found, a
fallback is used, that simply returns the value closer to *factor* - i.e. the
starting value if *factor* is less than 0.5 and the target value otherwise.
## Caching interpolators
To avoid having to look up the right interpolator every frame, you can cache
it:
```gdscript
extends Node3D
@export target_node
@export approach_time = 0.5
var interpolator
func _ready():
interpolator = Interpolators.find_for(global_transform)
func _process(delta):
# Approach target node, if it exists
if target_node:
var from_xform = global_transform
var to_xform = target_node.global_transform
var factor = delta / approach_time
global_transform = interpolator.call(from_xform, to_xform, factor)
```
## Custom interpolators
*Interpolators* supports interpolators for custom data types, and even
overriding built-in interpolators. Both can be done by registering an
interpolator:
```gdscript
Interpolators.register(
func(a): return a is float, # Condition
func(a, b, f): return lerpf(a, b, f * f) # Interpolation
)
```
The above registers a custom interpolator by specifying a condition function
and an interpolation function. Since it applies to an already supported type,
it overrides the built-in interpolator.
During lookup, *Interpolators* calls the *condition* function of each
interpolator and returns the one whose *condition* function returns true. If
multiple interpolators are applicable, *Interpolators* returns the last
registered one.
The *interpolation* function receives the starting value *a*, the target value
*b* and the interpolation factor *f*.
## Built-in interpolators
The following types are supported by default:
* float
* Vector2
* Vector3
* Transform2D
* Transform3D
[TickInterpolator]: ../nodes/tick-interpolator.md
+148
View File
@@ -0,0 +1,148 @@
# Logging
During runtime, it can be useful to print some diagnostic info to the console -
this is called logging. The netfox addons include a logging system to help with
debugging. This is useful when running the game locally, but also helps if
there's log files players can attach with their bug reports.
The system produces logs like this:
```
[DBG][@0][#1][_][netfox::NetworkPerformance] Network performance enabled, registering performance monitors
[DBG][@0][#1][_][netfox.extras::WindowTiler] Tiling with sid: f2682d1, uid: 17627261006193110
[DBG][@0][#1][_][netfox.extras::NetworkSimulator] Feature disabled
[DBG][@0][#1][_][netfox.extras::WindowTiler] Tiling as idx 0 / 1 - 17627261006193110 in ["17627261006193110"]
[DBG][@22][#1][_][netfox.extras::NetworkWeapon] Calling after fire hook for Bomb Projectile 5sswh7lcsgbq
[DBG][@27][#1][_][fb::Displacer] Created explosion at (2.027323, 1.500942, -14.99592)@26
[DBG][@34][#1][_][netfox.extras::NetworkWeapon] Calling after fire hook for Bomb Projectile u4h8opz52lin
[DBG][@46][#1][_][fb::Displacer] Created explosion at (4.892477, 1.500942, -14.83388)@45
[DBG][@46][#1][_][netfox.extras::NetworkWeapon] Calling after fire hook for Bomb Projectile 2u1d9n456yl1
[DBG][@57][#1][_][fb::Displacer] Created explosion at (4.814114, 1.500942, -14.57117)@56
```
This page will elaborate on how to produce your own logs, and what each part
means.
## Using the logger
The logging system can be accessed by creating an instance of `NetfoxLogger`.
Every logger has a name, and belongs to a module. Both of these can be
arbitrary strings, and are included in the logged messages.
Messages can be logged as different *logging levels*:
```gd
var logger := NetfoxLogger.new("my-game", "Player")
logger.trace("Detailed message")
logger.debug("Something happened")
logger.info("Hi!")
logger.warning("Couldn't connect")
logger.error("Game missing?")
```
To use string interpolation, you can also pass the template string and values
separately. This can be useful to avoid substituting the values in case the
message never gets printed because of filtering:
```gd
logger.trace("Adjusted clock by %.2fms, offset: %.2fms, new time: %.4fss", [nudge * 1000., offset * 1000., _clock.get_time()])
```
In the above example, there's a lot of data to be included in the message.
However, if trace logs are disabled, that data will never be substituted,
saving some processing time.
!!!tip
This same logging system is used by netfox itself.
## Log levels
Each log message can belong to one of the following categories:
Error
: Something goes irrecoverably wrong, or something that should never happen
just happened
Warning
: Something goes wrong, but can be handled
Info
: Useful information on expected behaviour
Debug
: Verbose messages, to help debug general code flow
Trace
: Extremely verbose messages, to help follow the code flow to the smallest
detail
Depending on your game, different logs may be needed. To accommodate this,
*netfox* can be configured in the [Project Settings](#settings) to omit certain
log messages.
Filtering based on log levels can also be configured from code. To set the
global log level, set `NetfoxLogger.log_level`. To configure the log level per
module, use the `NetfoxLogger.module_log_level` dictionary.
## Tags
Tags can be attached to the logging system. They provide pieces of information
that appear in each log message, for every logger.
By default, netfox provides a few tags, to help with debugging. These are, in
order:
Current tick
: The current tick, as per `NetworkTime`
Peer ID
: The currently active multiplayer peer's ID
Rollback status
: Contains the current rollback stage, simulated tick, and resimulated tick
interval.
The stage can be `B` for before loop, `P` for prepare tick, `S` for
simulate tick, `R` for record tick, and `A` for after loop.
The current tick is in the form of `X|A>B`, meaning we're currently
simulating tick X, in a loop going from tick A to tick B.
Defaults to `_` if currently not in rollback.
!!!note
These default tags are subject to change between releases.
Custom tags can be attached by calling `NetfoxLogger.register_tag()`. In this
sense, tags are callbacks that must return a single string, containing the tag
data to be logged.
This method takes a second, `priority` parameter. This priority is used to sort
them for logging - tags are printed from lowest priority to highest.
!!!warning
Make sure to free your custom tags using `NetfoxLogger.free_tag()`. Not
doing so might cause crashes. See [#433] for details.
## Settings
![Logging settings](../assets/logging-settings.png)
These settings control the *minimum* log level - e.g. if the log level is set
to *info*, only messages at or above the info level will be logged, namely
info, warning and error. If the setting is set to *all*, all messages are
logged.
Log levels can be controlled globally and per addon. A message will be logged
if it passes *both* logging level checks.
For example, if the *Log Level* setting is at *Warning* and the *Netfox Log
Level* is at *Info*, only warning and error messages are logged for netfox.
This happens because the *Log Level* is more restrictive than the *Netfox Log
Level* setting.
Note that you don't need to install all netfox addons for the logging settings
to work. If an addon is not installed, its log level setting is simply ignored.
[#433]: https://github.com/foxssake/netfox/issues/433
+98
View File
@@ -0,0 +1,98 @@
# Netfox Sharp
!!!warning
**[Netfox Sharp] is currently an experimental build and not ready for
production. During this time, breaking changes may be introduced at any
time.**
The [Netfox Sharp] addon is designed to bridge the gap between GDScript and C#
by allowing core netfox features to be accessed in C# without having to worry
about [Cross-Language Scripting] with GDScript directly.
### What Netfox Sharp Is
- A wrapper for netfox that uses the existing netfox codebase for its logic.
- A way to more conveniently call netfox logic in a C# environment.
- Partially compatible with existing codebases that use GDScript.
### What Netfox Sharp Isn't
- A standalone addon written entirely in C#.
- A perfect 1:1 translation. Due to quirks of netfox, some code will differ,
detailed below.
- A wrapper for netfox.noray or netfox.extras. Support for either of those
currently isn't planned, but may be considered based on interest.
## Getting Started
- Download the [Netfox Sharp] repo, and move the `netfox_sharp` and
`netfox_sharp_internals` folders into the addons of a C#-enabled Godot
project using the .NET version of Godot 4.x.
- Install the netfox addon. See the Netfox Sharp repo for details on which
version of netfox you need.
- Build your project, then enable netfox and Netfox Sharp in your project
settings.
- Restart Godot, and you've successfully set up Netfox Sharp!
## Differences Between Netfox And Netfox Sharp
Existing documentation for netfox should be easily translatable to Netfox Sharp
by following the below differences.
- Most changes follow Godot's rules for [Cross-Language Scripting], taking
netfox as the base. In netfox, consider the following:
```gdscript
# The following example is a snippet of netfox code
func _ready():
NetworkTime.before_tick_loop.connect(_gather)
func _gather():
# Input gathering here
pass
func _rollback_tick(delta, tick, is_fresh):
# Rollback logic here
pass
```
Whereas in Netfox Sharp:
```cs
// This is functionally identical Netfox Sharp code
public override void _Ready()
{
// All netfox autoloads like NetworkTime are accessed through static members
// in NetfoxSharp, to save on GetNode() calls and reduce clutter in the
// project settings.
// All members like BeforeTickLoop are in PascalCase, similar to Godot's C#
NetfoxSharp.NetworkTime.BeforeTickLoop += Gather;
}
// As Gather is linked to a signal, it can be any naming convention.
private void Gather()
{
// Input gathering here
}
// Since _rollback_tick isn't connected to a signal and is instead handled by
// netfox internally, netfox's naming convention must be followed.
public void _rollback_tick(double delta, long tick, bool isFresh)
{
// Rollback logic here
}
```
- Nodes in the add mode menu have similar names to the GDScript version, but
with 'Sharp' affixed, IE `RollbackSynchronizerSharp`. The GDScript versions
of the nodes are also present in the add node menu. This is a limitation of
how netfox interacts with Godot and cannot be removed.
# Other Notes
- `RollbackSynchronizerSharp`, `StateSynchronizerSharp`, and
`TickInterpolatorSharp` create their own respective GDScript nodes, which are
instanced as internal children nodes and should not be accessed.
[Cross-Language Scripting]: https://docs.godotengine.org/en/stable/tutorials/scripting/cross_language_scripting.html
[Netfox Sharp]: https://github.com/CyFurStudios/NetfoxSharp/
@@ -0,0 +1,94 @@
# NetworkCommandServer
Implements a simpler, lightweight alternative to RPCs. Provided as an autoload.
Commands consist of a single byte for ID, and the raw binary data. The ID lets
the receiving peer decide what to execute, with the binary data serving as the
input.
Being a simpler construct makes commands a good fit for regular, fundamental
operations. For example, commands internally are used for time synchronization,
or synchronizing state and input between peers.
Commands are, by default, transmitted over regular RPCs. To use less data,
commands can also be transmitted as raw packets, using
[SceneMultiplayer.send_bytes()]. This is an opt-in feature - if the game is
already using [SceneMultiplayer.send_bytes()], it needs to be aware of
commands, and must check each packet whether it's a command or one of its own
packets. To check if a packet is a command, use `is_command_packet()`.
## Implementing custom commands
Custom commands can be registered with the *NetworkCommandServer*, using
`register_command()`. This returns a *Command* object that provides a
convenient interface.
During registration, a callback must be provided, that will be ran when the
command is received.
Commands can be sent using its `send()` method.
```gdscript
@onready var cmd_message := NetworkCommandServer.register_command(handle_message, MultiplayerPeer.TRANSFER_MODE_UNRELIABLE)
func handle_message(sender: int, data: PackedByteArray) -> void:
var message := data.get_string_from_utf8()
print("#%d: %s" % [sender, message])
func _ready() -> void:
cmd_message.send("Hello, world!".to_utf8_buffer())
```
!!!tip
It is recommended to setup commands once, at game start. When registering
commands from autoloads, make sure they run *after* netfox's autoloads.
## Differences compared to RPCs
Commands are a fundamentally simpler constructs compared to RPCs.
### Maximum 256 commands
Commands are limited to 256 indices - make sure to not register more than that.
Some commands are registered by netfox on startup as well.
This limitation also makes commands a poor fit for registering dynamically.
Dynamic registrations often mean registering commands as certain nodes or
objects are created. This, in turn, makes it difficult to place an upper bound
on the number of commands needed, which can conflict with this limitation.
### Commands are not tied to any node
Commands do not refer to any specific node or object in their content. They
only contain a command index. Even though the API encapsulates this into
*Command* objects, it is completely feasible to have different nodes handle the
same command on different peers ( if the game is built as different Godot
projects ).
### Commands do not track authority
Any peer can send any command to any other peer. It is the receiving peer's
responsibility to check whether the sender is allowed to send such a command or
not.
### Commands do not have arguments
To stay lightweight and to give maximum control, commands contain raw bytes
only, no arguments.
In general, this can be worked around by wrapping the arguments in an array and
converting it using [var_to_bytes()] and [bytes_to_var()].
However, for cases where bandwidth matters, this allows users to encode data in
a way that fits best.
## Settings
netfox ▸ General ▸ Use Raw Commands
: When enabled, netfox will transmit commands as raw packets, instead of RPCs.
[SceneMultiplayer.send_bytes()]: https://docs.godotengine.org/en/stable/classes/class_scenemultiplayer.html#class-scenemultiplayer-method-send-bytes
[var_to_bytes()]: https://docs.godotengine.org/en/stable/classes/class_%40globalscope.html#class-globalscope-method-var-to-bytes
[bytes_to_var()]: https://docs.godotengine.org/en/stable/classes/class_%40globalscope.html#class-globalscope-method-bytes-to-var

Some files were not shown because too many files have changed in this diff Show More