Web Screen — Minecraft 1.21.1 NeoForge “Web Block”
Place a screen in Minecraft that can actually browse the internet: each unit is 1 block wide and 2 blocks tall (strictly 1:2). Right-click with an empty hand to enter a URL, and web pages (including videos) will play directly on the block surface. Place multiple units horizontally or vertically and they will automatically merge into one large screen, with the image seamlessly filling the entire surface.
Under the hood, it uses MCEF (Minecraft Chromium Embedded Framework) to embed a Chromium browser into the game, so it’s not just displaying an image — it’s actually running web pages.
1. Dependencies
| Name | Version | Notes |
|---|---|---|
| Minecraft | 1.21.1 | |
| NeoForge | 21.1.x (recommended 21.1.257) | |
| MCEF | 2.1.6-1.21.1 (NeoForge version) | Required, client-side only |
MCEF is a client-side mod. The server does not need it, but every player’s client must have it.
Download: https://modrinth.com/mod/mcef or https://www.curseforge.com/minecraft/mc-mods/mcef
File name like: mcef-neoforge-2.1.6-1.21.1.jar
2. Installation
-
Use the official NeoForge installer to install Minecraft 1.21.1 + NeoForge.
-
Drop the following two jars into
.minecraft/mods/(already provided input-in-mods/in this repo):-
mcef-neoforge-2.1.6-1.21.1.jar -
webscreen-1.0.0.jar
-
-
Launch the game.
-
First launch will take a while: MCEF needs to download a Chromium runtime (about 100+ MB) from
mcef-download.cinemamod.com. Please be patient and do not close the game.
3. Usage
Placement and Controls
| Action | Effect |
|---|---|
| Creative mode “Web Block” tab / crafting | Obtain the Web Block |
| Right-click on the ground | Place automatically occupying two blocks upward, front facing you |
| Shift + Right-click block | Open URL input screen (right-click on an empty screen also works) |
| Enter URL → click “Play” | Web page starts rendering on the block |
| Click “Stop” | Clears URL, screen returns to standby black |
| Press F8 (crosshair pointing at screen) | Enter “Web Input Mode”, type/login directly |
| Shift + Left-click | Break block |
| Breaking either top or bottom block | The whole unit drops together, only 1 item |
Wall Placement
The panel is attached to the back of the block (not floating in the middle), so when you place the block against a wall, the screen looks flush with the wall, rather than “floating half a block away from the wall in the air.”
If the top surface of the block your cursor is pointing at is occupied (common when placing against walls or ceilings), placement will automatically move down one block and try again, instead of failing outright. So:
-
Stand a bit farther away, aim at the lower part of the wall → right-click → the block will automatically attach to the base of the wall and occupy two blocks upward.
The block model, collision box, and rendering panel all use the same coordinate set (tools/quad_check.py has static verification). Whichever direction it faces, the panel is attached to the opposite side.
No Longer Transparent from the Back
The front of the panel is single-sided, so in early versions you could “see through” from the back to the front image. Now each unit has an additional solid back panel on the back side, matching the border color, so from any angle it looks like a solid shell, with no see-through artifacts.
Building a Large Screen
Place multiple units side by side (left/right) or stacked vertically, and they will automatically merge into one large screen. Note that each unit itself occupies 2 blocks of height, so when stacking upward, the lower half of the upper unit should be placed 2 blocks above the current unit:
┌────┬────┐
│ │ │ ← each cell is a "unit" (1 block wide x 2 blocks tall)
│ │ │
├────┼────┤
│ │ │
│ │ │
└────┴────┘
2x2 units = 2 blocks wide × 4 blocks tall
The entire large screen runs only one browser instance, and the image is split and continuously attached to each unit, with internal seams perfectly aligned and no stretching; the “monitor bezel” with borders only remains on the outermost ring of the whole screen, so you won’t see black lines when tiled.
Right-clicking any unit enters the same URL.
Newly added units automatically inherit the currently playing URL and won’t turn black.
Limit: maximum 8 blocks horizontally, maximum 4 units vertically (= 8 blocks tall). Excess parts will be split into separate screens.
When the shape is not rectangular (e.g., L-shaped), it will automatically split into several rectangular screens maximizing area, minimizing waste. For example, a 3-block L-shape will be split into “1x2 + 1x1” two screens.
Interacting with Web Pages
Core principle: when Shift is not pressed, left/right clicks are fully passed to the web page; holding Shift means “operating the block”.
| Action | Effect |
|---|---|
| Crosshair pointing at screen | Mouse position synced to web page in real time (hover effects, video progress bar) |
| Left click / Right click (without Shift) | Left/right click on web page (clicking “Play” on a video is left click) |
| Shift + Left click | Vanilla mining, break the block |
| Shift + Right click | Open “Enter URL” screen |
| Scroll wheel | Scroll web page up/down |
| Press F8 | Enter “Web Input Mode” (see below) |
Pointing at the screen does not steal the system mouse pointer — the cursor is always managed by the game.
To break the screen or change its URL, hold Shift and click.
When the screen hasn’t loaded a web page yet (or Chromium is still initializing), it doesn’t count as a “screen”, so left/right clicks are normal block operations — right-click without Shift will also directly open the URL input screen, so an empty screen isn’t unclickable and unbreakable.
One implementation note: Shift + Right-click must be intercepted by this mod itself to open the screen; you cannot rely on vanilla block interaction. Because when the player is sneaking and holding an item, vanilla skips block interaction and instead “places the held block”, so the URL input screen would never appear.
Tab Block (Switching Between Multiple URLs)
You can attach tab blocks (half-block high) on top of the screen; one tab = one URL. A screen displays only one activated tab at a time, so you can switch back and forth like a browser:
[ Tab A ][ Tab B ][ Tab C ] <- tab blocks, attached on top of the screen ┌─────────────────────────┐ │ │ │ Screen │ <- displays the URL of the currently active tab │ │ └─────────────────────────┘
| Action | Effect |
|---|---|
| Right-click tab block | Switch its URL to the screen (activate) |
| Shift + Right-click tab block | Change this tab’s URL |
| Break tab block | Close this tab |
The active tab is bright blue, others are dark gray, each showing a line of domain name. Placement convention: directly above the top edge of the screen (with the upper half of the unit in between), orientation automatically aligns with the screen below.
It also serves as the landing point for “new window links”. Links like Bilibili user avatars / profile pages with target="_blank" would normally request a new window — but in OSR mode there’s nowhere to pop it up, so clicking does nothing. With tab blocks:
-
If there is an idle tab block on top of the screen → automatically occupy it and open as a new tab;
-
If not (no tab blocks placed, or all occupied) → the current screen navigates directly there (“close current window and open new”).
Underlying reason: MCEF runs in off-screen (OSR) mode, there is no real window to pop up. When a web page requests a new window, CEF has nowhere to open it, so clicking does nothing.
Fix (learned the hard way, don’t take the wrong path): Initially I registered CefLifeSpanHandler as usual, but actual logs showed that in OSR mode CEF never calls back onBeforePopup — the handler was registered successfully but never triggered once. So the real work is done by in-page JS injection: the injected script intercepts all target="_blank" clicks, form submissions, and window.open, and sends the URL back to Java via jcef’s cefQuery (CefMessageRouter). Java then decides whether to open a new tab or navigate in the same window. lifespan / request handler are still kept as a fallback.
Two implementation points: addMessageRouter must be called before any browser is created; CefMessageRouter must be held by a static field as a strong reference, otherwise after GC the functions in the page become invalid.
Web Input Mode (F8)
Point the crosshair at the screen and press F8 to enter an input interface that does not pause the game and locks the mouse. All keyboard keys (letters, numbers, backspace, enter, arrow keys, Tab, cut/copy/paste shortcuts, etc.) are forwarded to Chromium in the web page as-is:
-
You can search, log in, enter username/password directly on the screen;
-
Esc or press F8 again to exit;
-
Because the game is not paused, the view, crosshair, and other mods’ states won’t be disrupted when exiting.
There’s an underlying pitfall: MCEF’s sendKeyPress does not do key code conversion, directly stuffing GLFW key codes into CefKeyEvent.windows_key_code. So this mod maintains its own GLFW → Windows VK mapping table, otherwise backspace, enter, arrow keys, etc. would all fail.
URLs automatically prepend https://; protocols like javascript: / data: / file: are rejected.
4. Login State: Importing Browser Login Info
The embedded Chromium uses an independent profile (cached in .minecraft/mods/mcef-cache), and does not share login state with your local Chrome / Edge. So for cases like Bilibili where “login pops up halfway”, you need to log in once inside. There are two ways:
Route A: Log in directly in-game (recommended, easiest)
Point crosshair at screen and press F8 → directly open the login page inside and enter username/password. After logging in once, cookies are persisted to mcef-cache, so you won’t need to log in again. The URL input screen also has a built-in “Bilibili Login Page” button; one click fills in the address.
Route B: One-click import cookies from your local browser
Use tools/import_cookies.py in the repo to decrypt cookies from your local Chrome / Edge, etc., write them as JSON that the mod can read, and the game will automatically inject them:
# List available browser profiles python tools/import_cookies.py --list # Export cookies for default video sites to .minecraft/config/webscreen-cookies.json python tools/import_cookies.py --browser edge # Specify domain / export all python tools/import_cookies.py --browser chrome bilibili.com python tools/import_cookies.py --browser chrome --all
The script will find the .minecraft directory itself (use --out if not found). In-game it checks this file every 2 seconds, and automatically re-injects after changes, no need to restart the game.
Supported input formats (any one works):
-
{"cookies": [...]}exported by this mod; -
JSON with a top-level array;
-
JSON exported by browser extensions (like Cookie-Editor / EditThisCookie), with fields
expirationDate(seconds) orexpires(milliseconds).
Note (Windows):
-
v10 / v11 prefixed cookies rely on DPAPI + AES-256-GCM decryption, the script can handle it;
-
But v20 prefix starting from Chrome 127+ uses App-Bound encryption, which cannot be decrypted offline; the script will automatically skip and prompt.
-
When the browser is running, its Cookies database is exclusively locked and cannot be read. Please completely exit the browser (after closing the window, the process often stays in the background; go to Task Manager and end
chrome.exe/msedge.exe). -
If you really can’t decrypt it, it’s fine — use Route A to log in once in-game.
5. Crafting Recipes
Glass Pane Glass Pane Glass Pane Glass Pane Daylight Sensor Glass Pane -> Web Block x1 Glass Pane Glass Pane Glass Pane Glass Pane Glass Pane Glass Pane Paper Web Block Paper -> Tab Block x8
6. Server / Multiplayer
It works. Tested on a dedicated server: runServer loads the mod normally, Done (5.252s)! For help, type "help", no errors.
Division of labor:
| Needs webscreen | Needs MCEF | |
|---|---|---|
| Dedicated Server | Yes | No (dependency marked side = "CLIENT") |
| Client | Yes | Yes |
The server only stores and syncs URLs, does not run any browser; the actual Chromium is on each player’s client. So server overhead is negligible; the burden is on each client (one Chromium instance per screen, max 8 simultaneous per client, evicted by least recently used).
Multiplayer behavior:
-
URLs are shared: what is stored in a screen / tab block is the same for everyone;
-
“Which tab is currently active” is per-player: switching tabs won’t affect others;
-
Login state (cookies) is per-player, independent;
-
New window links in pages are also handled per-player: if there’s an empty tab block, open a new tab; if not, navigate in the current screen.
Known limitation: when two people click a new window link at the same time, they may grab the same idle tab (the later write overwrites the earlier) — the server currently only does “write”, no arbitration for “whether this tab is already occupied”. Basically won’t happen when playing with friends.
Server-side notes:
-
After the server installs webscreen, clients must also install it (mod list must match, otherwise they can’t connect);
-
Do not install MCEF on the server — that dependency is declared client-side, useless on the server;
-
Single-player save (“Open to LAN”) is the same: the host is both server and client, both are needed (already in the modpack).
7. Building from Source
Requires JDK 21 (NeoForge 1.21.1 requirement).
cd webscreen gradlew.bat build # Windows ./gradlew build # macOS / Linux
Output: build/libs/webscreen-1.0.0.jar
Development environment: run client directly for debugging (automatically puts MCEF on the runtime classpath):
gradlew.bat runClient
If runClient randomly crashes on startup
Symptom: game crashes directly during Initializing game, crash report shows:
java.lang.RuntimeException: java.util.concurrent.ExecutionException:
java.nio.file.FileSystemNotFoundException
at net.neoforged.fml.earlydisplay.DisplayWindow.setupMinecraftWindow(...)
at cpw.mods.niofs.union.UnionFileSystemProvider.getFileSystem(...)
Same project, same parameters, sometimes starts sometimes crashes — this is an upstream race condition bug in NeoForge / securejarhandler (NeoForge maintainer ChampionAsh5357 also reproduced the same crash report), unrelated to this mod: when it crashes, MCEF / Web Screen haven’t even started in the mod list.
Root cause is that the rendering thread of the early loading window reads classes from its own module, but the union file system can’t find the corresponding jar:
// cpw.mods.niofs.union.UnionFileSystemProvider public FileSystem getFileSystem(URI uri) { String[] split = uri.getPath().split("!"); if (!fileSystems.containsKey(split[0])) throw new FileSystemNotFoundException(); return fileSystems.get(split[0]); }
This repo avoids it by default: neoforge.earlyWindowControl=false in gradle.properties is synced to run/config/fml.toml before running the game (NeoForge only reads this switch from this file, FMLConfig has no system property entry, so a syncFmlConfig Gradle task is used to sync). After disabling, ImmediateWindowHandler uses DummyProvider instead, completely avoiding DisplayWindow, and the EARLYDISPLAY line won’t appear in the log.
The only cost is losing that fancy loading animation. Once upstream fixes it, change it back to true.
If you really want the loading animation: change that line to true, then manually restart once when you encounter a crash — because it’s a race condition, retrying usually works.
8. Implementation Details
src/main/java/com/webscreen/ ├─ WebScreenMod.java # Common entry: register blocks/items/block entities/network packets ├─ ModBlocks.java # Block + item registration ├─ ModBlockEntities.java ├─ ModCreativeTabs.java ├─ util/UrlUtil.java # URL normalization + protocol whitelist ├─ network/ │ ├─ SetUrlPayload.java # Client -> Server: write URL (all units of the whole screen) │ └─ SyncUrlPayload.java # Server -> Client: broadcast current URL ├─ block/ │ ├─ WebScreenBlock.java # HALF (top/bottom) + FACING (four directions), two-block placement/break linkage, wall placement │ ├─ WebScreenBlockEntity.java # Stores URL, syncs to client with chunk │ └─ ScreenLayout.java # Core algorithm for multi-block tiling (stateless) └─ client/ ├─ WebScreenClientMod.java # Client entry (Dist.CLIENT), registers block entity renderer + F8 keybind ├─ KeyMappings.java # F8 = Web Input Mode ├─ ClientHooks.java # Open URL input screen / poll F8 ├─ CookieVault.java # Read config/webscreen-cookies.json and inject into CEF global cookies ├─ gui/UrlInputScreen.java # URL input GUI (includes "Web Input Mode" and "Bilibili Login Page" buttons) ├─ gui/RemoteControlScreen.java# Web Input Mode: no pause + lock mouse + full keyboard forwarding ├─ ScreenBrowserManager.java # One Chromium instance per screen, with LRU eviction ├─ WebScreenRenderer.java # Split Chromium texture by UV onto each unit + solid back panel └─ ClientInteractions.java # Mouse hover / click / scroll forwarding, crosshair picking tools/ ├─ gen_textures.py # Procedurally generate all textures for this mod ├─ import_cookies.py # Export cookies from local Chrome/Edge to webscreen-cookies.json ├─ layout_sim.py # Simulation test for layout algorithm └─ quad_check.py # Tiling geometry + UV seam simulation (64 items) + static consistency check
Panel Attached to the Back
The panel (element [0,0,11.5] → [16,16,15.5] in web_screen.json) occupies only the last 4/16 along the facing axis, with another 0.5/16 gap on the back, so it’s attached to the back side of the block, not floating in the middle. blockstates use y: 0/90/180/270 to rotate it to four directions; the rendering side and collision box independently recalculate the same set of backPlane / frontPlane, and all three must align:
facing=NORTH(y=0) z ∈ [11.5, 15.5] facing=SOUTH(y=180) z ∈ [ 0.5, 4.5] facing=EAST (y=90) x ∈ [ 0.5, 4.5] facing=WEST (y=270) x ∈ [11.5, 15.5]
tools/quad_check.py directly parses the model JSON, blockstates rotation angles, WebScreenBlock’s Block.box(...) expressions, and WebScreenRenderer’s PANEL_THICKNESS / PANEL_BACK_GAP constants, comparing each facing — if you change the model later and forget to change collision/rendering, running it once will catch it.
Why the Back Is No Longer Transparent
The web page quad is single-sided (disableCull), so from the back you’d see a mirrored image. So each unit draws a solid back panel on the inner side of the panel back, filling the entire cell with reversed vertex order (color taken from the border color rgb(26,29,35)), reversed vertex order ensures the back observer sees the front facing them. Before rendering, explicitly set depth state (enableDepthTest / depthMask(true) / depthFunc(GL_LEQUAL)), and restore enableCull / disableBlend uniformly at the end, to avoid state leakage on exception paths.
Black Border Only on the Outermost Ring of the Whole Screen
The block model itself has a “monitor bezel” (web_screen_bezel texture). If each cell drew its own, tiling would show a black line on every internal seam. So when rendering:
-
The browser image covers the entire front of the cell — the bezel of internal units is completely covered by the image;
-
Only the edges falling on the outermost ring of the whole screen (
col == 0/col == width-1/row == 0/row == height-1) are inset byMARGIN_X/MARGIN_Y, leaving the outer frame.
Determining “whether it’s an outer edge” only looks at this cell’s col / row in the layout, regardless of neighbors, so the vertex coordinates and UVs calculated by the two units on either side of the seam are exactly equal, and the image fits perfectly. tools/quad_check.py verifies this for 4 facings × 16 shapes: common edge world coordinates match, UVs match, UVs fill [0,1], outer frame does not overflow into internal units.
Why a Single Cell Is Strictly 1:2
Each cell leaves margin m on left/right and 2m on top/bottom, so the visible image is always (1-2m) : (2-4m) = 1 : 2, default m = 1/16. Because each cell is 1 block wide × 2 blocks tall, the overall ratio of the tiled large screen is still width : 2*height; the browser viewport is also created with the same ratio (blockPixels scaled by screen cell count, overall no more than 2048 px, single cell 48~512 px), no stretching distortion.
Note: margins are calculated for the whole screen, not per cell. So an 8x4 large screen viewport is (8-2m) : (16-4m), also on the 1:2 scale and strictly proportional to the visible area. Mouse coordinate to web pixel conversion (ClientInteractions) uses the same formula, ensuring click accuracy.
Screen Layout Algorithm (ScreenLayout)
This is the key to multi-cell tiling, deliberately designed to be stateless:
-
Starting from any cell, BFS finds all adjacent complete units with the same facing (connected component).
-
Map the connected component to a grid (note that a unit occupies 2 blocks vertically, so row number is divided by 2).
-
Greedily cut rectangles in “top to bottom, left to right” order: each time take the rectangle with the current cell as top-left corner and maximum area.
-
Each cell independently goes through this process, obtaining the top-left coordinate of the rectangle it belongs to + its own
(col, row).
Because the algorithm is deterministic, every cell in the same connected component will produce the same result, so no layout data needs to be synced — layout updates naturally when placing/breaking blocks online, no special handling for splitting or merging.
tools/layout_sim.py ports this algorithm to Python for simulation, covering 4 facings × 16 shapes (including L, T, cross, oversized, disconnected, etc.), verifying round-trip coordinates, no overlap, full coverage, 64 items all passed.
Other Trade-offs
-
Server only stores URLs, browser instances only created on client, zero server overhead.
-
Simultaneous browser limit (8), evicted by LRU to prevent VRAM exhaustion; immediately recycled when block is broken, chunk unloaded, or the cell is no longer the top-left of a screen.
-
Block entity only exists in the lower half; the upper half is just a placeholder to make the shape 2 blocks tall.
-
Disabled MCEF’s web cursor takeover (
setCursorChangeListenerset to null), otherwise when the mouse hovers over a link in the web page, it would “unlock” the game mouse and the view couldn’t rotate.